Skip to content

F# hot reload: Edit-and-Continue delta emission behind --test:HotReloadDeltas#19941

Draft
NatElkins wants to merge 126 commits into
dotnet:mainfrom
NatElkins:hot-reload-v2
Draft

F# hot reload: Edit-and-Continue delta emission behind --test:HotReloadDeltas#19941
NatElkins wants to merge 126 commits into
dotnet:mainfrom
NatElkins:hot-reload-v2

Conversation

@NatElkins

@NatElkins NatElkins commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

This draft PR presents the complete F# hot reload implementation as a single branch against current main: dotnet watch (with a companion dotnet-watch change, linked below) patches running F# processes in place via the standard EnC pipeline (MetadataUpdater.ApplyUpdate) — the same runtime contract C# uses. Unsupported edits degrade to the rebuild-and-restart flow watch uses today, so a limited scope still behaves as a complete feature.

It is opened as a single draft per discussion with @T-Gro: one branch to review, build, and launch testing from. A decomposition into independently shippable PRs is laid out below and can begin whenever review reaches that stage. Earlier discussion: #11636.

Try it (30–45 min, two clones, copy-paste steps)

docs/hot-reload-quickstart.md walks from git clone to editing a running F# app — including adding a List.map (fun s -> s.ToUpper()) to a live method and watching it apply in place, state preserved. Short version: build this branch, build NatElkins/sdk fsharp-hotreload-watch-v2 (a complete .NET CLI with an F#-aware dotnet watch), copy the freshly built FSharp.Compiler.Service.dll into the SDK layout, add <OtherFlags>$(OtherFlags) --test:HotReloadDeltas</OtherFlags> to a console app, and dotnet watch run.

What works

  • Method body edits, including bodies containing closures, async, resume-point-stable task, and generics
  • Resumable state-machine shape edits (behind --test:HotReloadClassStateMachines): adding or removing a let!/do! in task and backgroundTask, which emits class-form (reference-type) state machines so the change is an AddInstanceFieldToExistingType plus a method update, matching C#. taskSeq and other resumable CEs share the same lowering path. Off by default; flag-off codegen is byte-identical.
  • Lambdas added / edited / removed across generations (new closure classes synthesized into the running process)
  • Member additions: methods, module functions and values, static/instance fields, properties, [<CLIEvent>] events
  • New type definitions: classes, records, unions, structs, modules, enums, interfaces, delegates, units of measure
  • Attribute add/change/remove on existing members; parameter renames
  • Multi-project: one watch session, per-project baselines, interleaved edits across an app and its referenced libraries
  • Line-shift edits (comment/whitespace above code) become pure sequence-point line updates — no delta, no restart
  • Rude edits (signature changes, captured-variable rename/type/scope changes, plus the F#-specific inline-annotation change) degrade to the standard rebuild-and-restart flow with a precise diagnostic. Adding or removing a mid-sequence let!/do! is rude under the default struct state machines, but becomes a supported edit (an AddInstanceFieldToExistingType plus a method update, matching C#) under --test:HotReloadClassStateMachines

Isolation and disabling

The feature is designed so that a compile without the flag is indistinguishable from main, and so that the whole feature can be turned off at one point if something goes wrong:

  • Off by default. Everything is behind --test:HotReloadDeltas (requires --debug+, incompatible with --optimize+), with class-form resumable state machines a further opt-in behind --test:HotReloadClassStateMachines. Both are off by default; no MSBuild property or SDK behavior changes in this repo.
  • Flag-off output is byte-identical to main, pinned by the EmittedIL suite (1212 baseline tests) and by dedicated determinism tests (DLL+PDB byte-equality across recompiles and across graph/sequential checking modes).
  • Flag-off cost is zero beyond cheap checks. A dedicated audit pass removed all unconditional work from the flag-off path: no reflection, no eager metadata snapshots, no per-name or per-closure side-channel probes, no extended lifetime of the optimized typed tree, and FSharpProjectSnapshot.fs is byte-identical to main (tracked-input staleness lives in the hot reload session layer instead).
  • One seam. The compiler driver integration is a single ICompilerEmitHook interface with a no-op default. Guard scripts under tests/scripts/ (run by the verification gate) enforce that only the intended files consume the hook and that the IlxGen name-generation path and fsi surface stay on their pinned shapes.
  • Disabling: drop the flag (compiler side); the companion dotnet-watch side additionally has a one-variable kill switch (DOTNET_WATCH_FSHARP_HOTRELOAD=0) that restores stock restart-on-edit behavior.

Architecture

  • Sessions are an explicit entity: FSharpChecker.CreateHotReloadSession returns a FSharpHotReloadSession holding per-project committed snapshots + emit baselines — the DebuggingSession/CommittedSolution shape from Roslyn, built on FSharpProjectSnapshot (the snapshot contract, not the experimental workspace surface), so it composes with the FSharpWorkspace direction without depending on it. Solution-wide commit/discard semantics, runtime-capability updates, active-statement intake.
  • A typed-tree semantic diff classifies every edit (Roslyn's SemanticEdit/RudeEdit model), gated on the runtime's advertised EnC capabilities (AddMethodToExistingType, NewTypeDefinition, GenericUpdateMethod, …). Anything unclassifiable fails closed.
  • Delta emission produces the standard EnC triplet (metadata/IL/PDB deltas with EncLog/EncMap), validated against recorded Roslyn EmitDifference reference deltas, with mdv, and against CoreCLR ApplyUpdate in runtime tests.
  • Closure identity is solved the way Roslyn solves it, adapted to F#'s lowering: lambdas get stable identity from a typed-tree occurrence model (ordinal chains + LCS alignment rather than syntax offsets), persisted in Roslyn's exact portable-PDB EnC CDI blob formats (the encoder round-trips Roslyn's own blobs byte-identically), with deterministic occurrence-derived closure-class names so any process can reconstruct identity from the PDB alone.

Design documentation (rendered, on this branch)

Doc What it covers
hot-reload-architecture.md Start here. The entity model: FSharpHotReloadSession, per-project committed snapshots + baselines on FSharpProjectSnapshot, the FSharpWorkspace relationship, determinism pins
hot-reload-closure-mapping.md The closure problem and its solution: lambda occurrence model, Roslyn-format EnC CDI PDB blobs, occurrence-derived deterministic closure naming, cross-process reconstruction; state-machine handling
hot-reload-member-additions.md Recorded Roslyn EmitDifference reference templates (EncLog/EncMap shapes per edit kind) and the F# emission matrix incl. every intentional fail-closed case
hot-reload-capabilities.md Runtime capability negotiation (Roslyn EditAndContinueCapabilities parity) and per-capability gating
hot-reload-active-statements.md The debugger-contract mirror: active statements, sequence-point updates, remapping; host wiring deferred

Scale and review shape

~118 commits, 159 files, ~60k insertions, of which ~34k are tests and ~2.4k docs. The src/ changes are predominantly new self-contained modules (IlxDeltaEmitter.fs, TypedTreeDiff.fs, HotReloadBaseline.fs, the AbstractIL EnC readers, the delta writer stack). Pre-existing files carry hook callouts plus one behavior-neutral refactor (ilwrite.fs MetadataTable record→class, exposing a baseline-row access seam for the delta writer); total deletions across the branch are 241 lines.

Proposed path to merging in pieces

The fail-closed design means scope can grow capability by capability — each unsupported case is already a rude edit with a diagnostic, so every intermediate state is a complete, working feature. The natural sequence:

  1. Behavior-neutral AbstractIL/ilwrite foundations (no feature, byte-identity evidence) — already staged separately as a 3-commit branch (refactor: AbstractIL EnC foundations (hot reload stack 1/n) NatElkins/fsharp#2)
  2. Session entity + typed-tree classification, with every edit classified rude — end-to-end complete at minimal scope (watch restarts on every edit, but through the proper pipeline). This is the PR where the architecture gets decided with reviewers.
  3. Method-body-only deltas (the core)
  4. Closure mapping + deterministic naming (the one chunk that touches IlxGen/name-generation paths — reviewed on its own)
  5. Member/field/type additions, generics, state machines — each already individually capability-gated
  6. Active statements / sequence-point updates

Evidence

Known limitations / future work

Companion PRs

claude and others added 30 commits June 10, 2026 14:27
…eaps, row indexing, EncLog writer)

Ported verbatim from the hot-reload prototype branch onto current main:
- ILDeltaHandles.fs: typed handles for EnC delta metadata rows
- ILMetadataHeaps.fs: heap accounting shared by baseline and delta passes
- ILRowIndexing.fs: row index helpers for delta table emission
- ILEncLogWriter.fs: IEncLogWriter abstraction recording EncLog/EncMap entries
- ilbinary.fs: additive table/coded-index definitions required by EnC emission
- EnvironmentHelpers.fs: FSHARP_HOTRELOAD_* environment variable helpers
- Caches.fs: qualify System.Guid to avoid shadowing from new helpers

All additions are dormant without the hot reload flag; no behavior change
for normal compilation.
…ble name-map state

- Generated/GeneratedNames.fs: central helpers for synthesized member/type names
- Generated/CompilerGeneratedNameMapState.fs: capture/replay of name-generator
  state so successive hot reload generations synthesize identical names
- CompilerGlobalState.fs: thread name-map state through NiceNameGenerator
- IlxGen.fs: route compiler-generated local names through a single freshIlxName
  helper (replaces direct IlxGenNiceNameGenerator.FreshCompilerGeneratedName
  call sites) and add IlxGenEnvSnapshot/snapshotIlxGenEnv/restoreIlxGenEnv to
  capture the codegen environment a delta generation must replay
- IlxGen.fsi: expose the snapshot API

3-way merge against current main verified: all 10 freshIlxName sites intact,
no new upstream name-generator call sites bypass the helper, net diff vs main
matches the prototype footprint exactly (+85/-49).
- TypedTree/SynthesizedTypeMaps.fs: stable maps for synthesized (closure/
  state-machine) types across generations
- TypedTree/TypedTreeDiff.fs: semantic diff between baseline and updated
  typed trees - binding/tycon snapshots, FNV-1a digests, rude-edit
  classification (signature changes, constraint changes, mutable-field
  toggles, type layout changes)
- HotReload/DefinitionMap.fs: maps baseline definitions to updated symbols

Static port validation against current main: every TypedTreeOps symbol
consumed by TypedTreeDiff.fs resolves in the refactored namespace (upstream
split TypedTreeOps.fs into TypedTreeOps.*.fs with [<AutoOpen>] modules
inside namespace FSharp.Compiler.TypedTreeOps, so consumer opens are
unchanged).
- ilwrite.fs/.fsi: thread IEncLogWriter through metadata emission; expose
  ILTokenMappings, MetadataHeapSizes, MetadataSnapshot and
  WriteILBinaryInMemoryWithArtifacts so a compilation can capture the token
  maps and heap layout a later delta generation must build on;
  markerForUnicodeBytes made public for delta #US emission (ECMA-335
  II.24.2.4)
- ilwritepdb.fs: portable PDB hooks for baseline capture
- ILBaselineReader.fs: pure F# reader recovering baseline metadata state
  (row counts, heap sizes, GUID heap start) from emitted images
- HotReloadBaseline.fs: baseline snapshot assembly for hot reload sessions
- HotReloadPdb.fs: PDB delta support shared across generations

3-way merged cleanly against current main; upstream MethodDefKey/Codebuf
changes (compareILTypes etc.) retained. Flag-off emission path is unchanged:
full builds use the no-op EncLog writer (createNullEncLogWriter).
EnC delta (#~, #Strings, #US, #Blob, #GUID) construction, ported verbatim:
- FSharpSymbolChanges.fs: symbol-level change set consumed by the writer
- IlxDeltaStreams.fs: per-generation heap/stream builders (delta-local
  offsets, generation-aligned Blob/US heaps)
- FSharpDefinitionIndex.fs: definition-to-token index over the baseline
- DeltaMetadataEncoding.fs / DeltaMetadataTypes.fs / DeltaMetadataTables.fs:
  ECMA-335 II.22/II.24 row encodings, coded indices, table models
- DeltaTableLayout.fs / DeltaIndexSizing.fs: row layout and wide/narrow
  index sizing for delta images
- DeltaMetadataSerializer.fs: serializes EncLog/EncMap-ordered tables
- DeltaMetadataSrmWriter.fs: System.Reflection.Metadata-based parity writer
  (FSHARP_HOTRELOAD_COMPARE_SRM_METADATA)
- FSharpDeltaMetadataWriter.fs: top-level per-generation metadata delta
  writer (EncId/EncBaseId chaining)
- SymbolMatcher.fs: matches baseline symbols to updated tree symbols
- HotReloadAccessorTypes.fs: synthesized accessor type support
- IlxDeltaEmitter.fs: per-generation IL emission for added/updated methods,
  async/state-machine @hotreload type synthesis, rude-edit guarded
  (HotReloadUnsupportedEditException instead of failwith on unresolvable
  references)
- HotReloadState.fs: per-session mutable state (generation chain, name maps)
- DeltaBuilder.fs: orchestrates diff -> symbol matching -> emission
- HotReloadCapabilities.fs: capability flags (Baseline, AddMethodToExistingType, ...)
- HotReloadContracts.fs: cross-layer contracts for tooling integration
- RudeEditDiagnostics.fs: rude-edit kinds and diagnostic mapping
- EditAndContinueLanguageService.fs: Roslyn-shaped EnC entry point; rejects
  all rude edits before invoking the emitter
- Adapters.fs: adapter layer between FCS session API and EnC service
- CompilerOptions.fs: --enable:hotreloaddeltas (baseline capture emission)
  and --enable:hotreloadhook (synthesized-name replay only)
- CompilerConfig.fs/.fsi: emitCaptureArtifacts config and emit-hook wiring
- CompilerEmitHookState.fs / CompilerEmitHookBootstrap.fs /
  HotReloadEmitHook.fs: pluggable emit hook capturing baseline artifacts
  (token maps, metadata snapshot, PDB) during normal fsc emission
- fsc.fs: invoke the emit hook around main4-main6 binary emission
- fsi.fs: qualify System.Guid (shadowing from new compiler-level opens)
- FSComp.txt: fscHotReloadRequiresDebugInfo (2026),
  fscHotReloadIncompatibleWithOptimization (2027) - IDs verified free on
  current main; xlf regeneration deferred to a follow-up commit (requires
  a build with /p:UpdateXlfOnBuild=true)

Flag-off compilations bypass the hook entirely (null EncLog writer).
- service.fs/.fsi: session surface the IDE/dotnet-watch layer consumes:
  FSharpChecker.StartHotReloadSession (2 overloads), EmitHotReloadDelta
  (2 overloads), EndHotReloadSession, HotReloadSessionActive,
  HotReloadCapabilities; plus FSharpHotReloadError, FSharpHotReloadDelta,
  FSharpAddedOrChangedMethodInfo, FSharpHotReloadCapability/-ies types
- FSharpProjectSnapshot.fs: snapshot support for session baselines
- FSharpCheckerResults.fs: expose typed-tree access needed by the differ
  (sessions require keepAssemblyContents=true)

One textual conflict resolved in the service.fs open block (upstream added
FSharp.Compiler.Caches at the same spot the prototype added
ILDynamicAssemblyWriter/ILPdbWriter; kept all three). Upstream's new
single-argument DiagnosticSink signature is unaffected - ported code
implements no DiagnosticsLogger. Net diff vs main matches the prototype
footprint (+927 service-layer lines).
- tests/FSharp.Compiler.Service.Tests/HotReload/ (18 files): unit tests for
  TypedTreeDiff, delta metadata writer, coded indices, ILBaselineReader,
  portable PDB reader, SRM parity, rude edits, thread safety, generated
  names, error/edge paths (258 tests on the prototype branch)
- tests/FSharp.Compiler.ComponentTests/HotReload/ (15 files): integration
  tests - baseline capture, delta emission, runtime MetadataUpdater
  ApplyUpdate scenarios, mdv validation, PDB generations (101 tests)
- FSharpWorkspace.fs: workspace plumbing used by hot reload session tests
- HotReload.runsettings sets DOTNET_MODIFIABLE_ASSEMBLIES=debug and
  COMPlus_ForceEnc=1 for ApplyUpdate runs; NOTE: repo moved to
  Microsoft.Testing.Platform since the prototype - verify
  RunSettingsFilePath is still honored when running these suites
- mdv-based tests honor FSHARP_HOTRELOAD_MDV_PATH

Test fsproj compile items re-applied onto the MTP-migrated project files
(anchors verified; 3-way merge clean).
…hook, release note

- tests/scripts/: hot-reload-verify.sh (full pipeline gate),
  hot-reload-demo-smoke.sh (HOTRELOAD_SMOKE_RUNTIME_APPLY runtime apply),
  metadata coupling/parity and plugin-boundary guards,
  check-ilxgen-name-path.sh, main-fsi drift checks + allowlists
- tests/projects/HotReloadDemo/: console demo app with hot reload session
  driver (hotreload-session.json + edited DemoTarget.fs); registered in
  FSharp.slnx under /Tests/HotReloadDemo/ (upstream migrated .sln -> .slnx
  since the prototype, so the old FSharp.sln entries were re-expressed)
- eng/Build.ps1: Invoke-HotReloadDemoSmokeTest hook after testCoreClr and
  testDesktop runs, re-derived onto upstream's reworked test sections
  (TestSplit batching); runs the demo app with
  DOTNET_MODIFIABLE_ASSEMBLIES=debug and asserts the delta-emitted marker
- hot-reload-verify.sh updated to build FSharp.slnx
- tools/hot-reload/compare_roslyn.fsx: Roslyn delta comparison helper
- docs: debug-emit.md hot reload heap tracing section,
  hot-reload-tgro-closure-matrix.md, release note entry in 11.0.100.md

Deliberately not ported from the prototype branch: tmp.fsx (scratch),
HOT_RELOAD_REVIEW_CHECKLIST.md and CLAUDE.md (workflow files, not product);
xlf regeneration deferred (needs /p:UpdateXlfOnBuild=true build).
Mirror Roslyn's EditAndContinueCapabilities flags and parser semantics
(exact-name matching, unknown capability words ignored, the
AddDefinitionToExistingType aggregate) with an immutable typed model in
FSharp.Compiler.EditAndContinue. Runtime capability strings are parsed
once at the session boundary; a session always carries at least the
Baseline capability. Includes parser tests and a design doc.
…bilities

Thread the typed capability model through the hot reload session:
FSharpChecker.StartHotReloadSession accepts an optional capabilities
string sequence (Roslyn WatchHotReloadService parity), parsed once and
stored on the session state; when omitted the session is conservative
and assumes baseline-only support.

Method additions now require the AddMethodToExistingType runtime
capability. Without it the diff reports the new
RudeEditKind.NotSupportedByRuntime (FSHRDL016) naming the missing
capability, mirroring Roslyn's distinction between edits unsupported by
hot reload and edits the connected runtime cannot apply. Classification
consults a single capabilityForAddition seam so Phase B can flip field
additions from always-rude to capability-gated by implementing emission.
Rude-edit details now flow into the UnsupportedEdit error so hosts see
the missing capability.
… additions

Module-level values lower to a static field plus accessor methods with
initialization in the startup class constructor, so adding one requires both
AddStaticFieldToExistingType and AddMethodToExistingType; module-level
functions lower to plain static methods and require only the latter. Both
were previously unconditional DeclarationAdded rude edits. Instance fields
remain rude until field-row emission lands (Phase B2); an emission-level test
pins the cut line so a capability-enabled host gets a clean UnsupportedEdit
rather than a half-emitted delta.
Adds Field-table support end-to-end in the delta writer pipeline:
FieldDefinitionRowInfo row model, mirror table population, serializer
table wiring, and SRM shadow-writer parity (including Field in the
tracked parity tables).

EncLog follows the Roslyn pattern recorded from a hotreload-delta-gen
C# reference delta that adds a static field with an initializer: the
parent TypeDef row is logged with the AddField operation immediately
followed by the new Field row with the Default operation, and only the
Field row enters EncMap. The pairs are spliced between the Module entry
and Method entries so per-table EncLog sorting cannot separate them.

Writer-level test asserts the exact EncLog sequence, EncMap content,
and serialized table stream for an added static int32 field.
Added module-level values (let mutable x = ...) now emit complete,
runtime-appliable deltas: the static backing fields on the startup-code
class enter the Field table, accessors and the startup constructor are
emitted as added methods, and the added value is readable/writable via
its accessors after MetadataUpdater.ApplyUpdate.

Making the deltas actually apply required aligning the EncLog with what
CoreCLR's EnC applier (CMiniMdRW::ApplyDelta) and Roslyn emit for ALL
added member kinds: an added member logs its PARENT row tagged with the
Add* operation immediately followed by the member row with the Default
operation (TypeDef for methods/fields, MethodDef for parameters,
PropertyMap/EventMap for properties/events; map rows and MethodSemantics
rows are plain Default entries). The previous shape - the Add* op on the
member row itself - corrupted parent member lists at apply time and had
never been runtime-applied by any test.

Further fixes uncovered by the runtime-apply tests:
- EditAndContinueOperation numeric values corrected to the CLR/SRM codes
  (AddParameter=3, AddProperty=4, AddEvent=5; previously 4/5/6).
- Baseline #Strings size now uses SRM's trimmed size (StringHeap.TrimEnd
  semantics, Roslyn EmitBaseline parity); the padded stream size shifted
  every delta-heap string reference.
- Added member names/signatures are written into the delta heaps instead
  of reusing fresh-compile heap offsets, with signature blobs remapped
  through remapSignatureBlobWith.
- Return-parameter rows are no longer synthesized: the out-of-sequence
  seq-0 rows forced the CLR's indirect ParamPtr path and made ApplyUpdate
  reject the delta. ParamList of added methods stays monotone instead.

DeltaBuilder resolves the startup-code class constructor as an updated
method when the baseline already contains it (it is not a typed-tree
binding, so the diff cannot pair it). Added field tokens chain into the
next-generation baseline like added methods.

Initialization semantics (validated at runtime and documented): the
initializer runs lazily if the startup class was not yet type-initialized
(the test observes 41); an already-initialized type reads default(T).

New tests: checker-level delta validation for the added value, mdv
validation of the Field rows and AddField pairing, and runtime ApplyUpdate
tests for both added module functions and added module values. Instance
field additions remain rejected (Phase B2).
…ule values

Generation 2 adds a second mutable module value on top of the generation-1
field addition and applies it with MetadataUpdater.ApplyUpdate. This pins
baseline chaining: generation-1 field/method/property tokens resolve in the
chained baseline so only the new value is appended, and the startup-code
constructor added in generation 1 is re-emitted as an updated method body.

Also pins the already-initialized regime of the initialization semantics:
the startup class was type-initialized in generation 1, so the second value
reads default(int) rather than its initializer, while generation-1 state is
preserved across the update.
… the typed-tree diff

Replace the blanket lambda-shape digest equality check with a structured
per-member lambda occurrence model:

- Occurrence identity = traversal ordinal + parent-lambda chain + structural
  digest (curried arity, per-group parameter type identities, capture identity
  list, return type identity). Consecutive curried lambdas form one occurrence,
  matching IlxGen closure formation. Source ranges are diagnostics-only.
- Old/new alignment = two LCS passes (full structural digest, then shape-only),
  so inserting/removing/reordering lambdas aligns the surviving occurrences
  instead of reporting spurious removed+added pairs.
- Capture compatibility per matched pair with C#-parity classification:
  RenamingCapturedVariable, ChangingCapturedVariableType,
  ChangingCapturedVariableScope (cross-occurrence move post-pass), plus
  additions/removals (additions may become applicable in C4 via
  AddInstanceFieldToExistingType).
- Classification: pure body edits within an unchanged lambda set remain plain
  MethodBody edits; lambda-set changes stay LambdaShapeChange rude edits, now
  with counts/ordinals in the message and a structured LambdaEdits payload on
  TypedTreeDiffResult for the C4 emitter.
- Quotations, object expressions, local type functions, and types without a
  computable runtime identity keep the legacy whole-body digest path unchanged.

Move the closure-mapping design doc into docs/ with C1 implementation notes.
Add EncMethodDebugInformation (CodeGen), replicating byte for byte the three
Roslyn EnC CustomDebugInformation blob formats persisted per method in
portable PDBs (EnC Local Slot Map, EnC Lambda and Closure Map, EnC State
Machine State Map), including the syntax-offset-baseline optimization and
the slot-map kind/ordinal byte packing. In F#, the syntax-offset slots carry
C1 occurrence keys packed from the occurrence ordinal chain (16-bit
segments, fail-closed past limits).

Tests cover round-trips (temps, ordinal-flagged slots, negative baselines,
static/this-only closure ordinals, negative state numbers), golden bytes,
fail-closed limits, and cross-validation: a C# library built with the repo
SDK has its Roslyn-emitted CDI rows decoded with the new decoder and
re-encoded byte-identically.

PDB writer/reader wiring is the next C2 commit.
… hotreloaddeltas

When --enable:hotreloaddeltas is on, the fsc emit path computes per-method
lambda occurrence data with the Phase-C1 extraction over the same optimized
typed tree the baseline capture snapshots, serializes it with the C2 blob
encoders, and carries it into the portable PDB writer through a new
methodCustomDebugInfoRows side channel on the IL writer options:

- TypedTreeDiff.collectMemberLambdaOccurrences: public C1 extraction over a
  CheckedImplFile, returning every member binding (empty occurrences for
  members the model cannot represent).
- EncMethodDebugInformation.computeMethodCustomDebugInfoRows: occurrences ->
  EnC Lambda and Closure Map blobs keyed by IL method (compiled) name, with
  occurrence keys packed from the C1 ordinal chains; MethodOrdinal stays
  UndefinedMethodOrdinal; one closure scope per occurrence (IlxGen lowers
  every occurrence to its own closure class). The EnC Local Slot Map is
  omitted: the lowered slot layout is an IlxGen artifact, not derivable from
  the typed tree.
- ilwritepdb attaches the rows as CustomDebugInformation on MethodDef parents,
  but only when the method name identifies exactly one method row; the
  producer likewise drops compiled names claimed by more than one member, so
  a map can never attach to the wrong method (overloads fail closed and
  simply carry no map).

Flag-off builds pass the empty map everywhere and emit byte-identical output
(EmittedIL gate: 1212 passed, 0 failed). New PdbCdiEmissionTests verify the
flag-on PDB carries a decodable map with occurrence keys [0] and [0; 1] for a
nested-lambda sample, and that the flag-off PDB contains no EnC CDI rows.
…sion

When a baseline is captured, decode every method-level EnC CustomDebugInformation
row of the baseline portable PDB (lambda/closure map plus the slot and state-machine
maps) into FSharpEmitBaseline.EncMethodDebugInfos, keyed by MethodDef token - the
CDI parent is a MethodDef handle, so token keying is unambiguous, unlike the
name keying on the write side which exists only because the PDB writer lacks
tokens. The fsc emit hook decodes the exact emitted PDB bytes; the checker path
additionally reads the on-disk PDB as a sibling input because its in-memory
baseline rewrite carries no CDI side channel. Fail safe/fail closed: flag-off and
pre-C2 PDBs decode to the empty map (sessions start fine), and a method whose
blobs do not decode is omitted rather than guessed.

Generation chaining: EmitDeltaForCompilation recomputes per-method occurrence
data from the fresh typed tree (computeRefreshedEncMethodDebugInfos, name-to-token
resolution fail closed on non-unique names) and chainEncMethodDebugInfos replaces
the updated methods' entries in the next-generation baseline, dropping entries the
fresh compile did not produce so stale data can never be matched. The delta PDB
does not yet re-emit EnC CDI rows; the in-memory chain is what C3 consumes, and
PDB persistence across session restarts is the documented remaining gap.
Add ClosureNameAllocator, the F# analogue of Roslyn's
EncVariableSlotAllocator.TryGetPreviousLambda/TryGetPreviousClosure
expressed over the C1 lambda occurrence model: a matched occurrence
(same two-pass LCS as the C1 diff, equal capture sets, recorded
baseline name) reuses the baseline closure class name verbatim; an
unmatched, capture-incompatible, or unmappable occurrence gets a fresh
generation-suffixed name ({base}@hotreload#g{gen}_o{ord}, the
DebugId(ordinal, generation) analogue); removed occurrences fall out of
the chain-forward table so their names are never reused.

The index-pair core of the C1 alignment is extracted into
TypedTreeDiff.alignLambdaOccurrenceIndexPairs and shared by both
consumers, so diff classification and name allocation can never
disagree about pairing. Pure data transformation: no IO, no IlxGen
state; covered by ClosureNameAllocatorTests (match/add/remove/nested/
capture-incompatible/fail-closed plus three-generation chaining).

Gates: service HotReload 365/365 (356 + 9 new), component HotReload
134/134.
Two layers of coverage for the C3 naming machinery:

- ClosureNameAllocatorTests now also drives the allocator over REAL C1
  extraction (checker compiles via the shared DiffTestHarness, made
  internal for reuse): adding a filter lambda yields a generation-2
  fresh name while the surviving map lambda reuses the baseline name,
  and generation 3 reuses both names from the chained table.

- New component ClosureIdentityTests pins the metadata-level invariant
  the delta path relies on today (and C4 extends): flag-on recompiles
  of an unchanged lambda set produce closure classes with identical
  names across three body-edit generations, so deltas can update the
  existing closure method bodies in place.

Gates: component HotReload 135/135, service HotReload 366/366,
EmittedIL 1212 passed / 3 skipped / 0 failed.
Bridge the C1 lambda occurrence model to IlxGen's closure naming seam so the
occurrence-keyed allocator can be wired into delta compiles:

- LambdaOccurrence gains RootExprStamp, the unique stamp of the occurrence's
  outermost Expr.Lambda (extraction bookkeeping, never part of the structural
  digest or alignment).
- GetIlxClosureFreeVars records stamp -> emitted closure type name into a new
  ConditionalWeakTable side channel (ClosureNameAllocationState, mirroring
  CompilerGeneratedNameMapState); recording is armed only by the emit hook for
  capture compiles, so flag-off output stays byte-identical.
- The fsc emit path joins the recording with the same tree's occurrence
  extraction (ClosureNameAllocator.computeBaselineClosureNameRows, fail closed
  on ambiguous compiled names and on members with incompletely recorded
  occurrences) and threads the per-method chain -> name tables through
  TryEmitWithArtifacts/CompilerEmitArtifacts into the baseline capture, where
  they are re-keyed by MethodDef token and stored as
  FSharpEmitBaseline.EncClosureNames alongside EncMethodDebugInfos.

Gates: compiler + both test projects build clean; component HotReload 136/136
(135 + new baseline-capture test), service HotReload 366/366, EmittedIL
1212/1212 (3 skipped).
Thread the C3 allocator into IlxGen lowering for session compiles:

- ICompilerEmitHook.PrepareForCodeGeneration now receives tcGlobals and the
  optimized impl files about to be lowered. When a session with baseline
  closure-name tables is active, the hook runs
  HotReloadBaseline.computeOccurrenceKeyedClosureNames (previous-generation
  occurrences vs fresh extraction, allocator per member with
  generation = session.CurrentGeneration) and installs the resulting
  stamp -> assigned-name table on the compiling CompilerGlobalState.
- The IlxGen closure call site consults the table FIRST and falls back to
  sequence replay; the replay-map slot is still consumed unconditionally so
  same-basename non-closure names keep their baseline replay positions.
  Surviving closures therefore reuse baseline class names verbatim even when
  the lambda set changes; added occurrences get {base}@hotreload#g{N}_o{i}.
- DeltaEmissionRequest.RefreshedClosureNameRows carries the allocator's
  refreshed per-method tables (recomputed deterministically at delta emission)
  and chainClosureNameRows chains them into the next-generation baseline with
  the chainEncMethodDebugInfos replace-or-drop semantics.
- Fail closed everywhere: no session, empty baseline tables, ambiguous
  compiled names, or unresolvable tokens leave lowering on pure sequence
  replay; flag-off compiles see a single failed weak-table lookup.

Gates: compiler + both test projects build clean; component HotReload 138/138,
service HotReload 366/366, EmittedIL 1212/1212 (3 skipped); the
multi-generation closure-identity and body-edit runtime tests now exercise the
allocator path live.
… in delta compiles

Component coverage for the C3 lowering wiring, driven through the session/hook
plumbing (flag-on compiles against an active session):

- three body-edit generations of a 2-lambda method keep closure class names
  byte-identical AND keep the chained session tables carrying the same names
  (the compiles now take the allocator path, not bare sequence replay);
- a recompile with a lambda ADDED IN FRONT of the survivors — the case
  sequence replay cannot handle — keeps every baseline closure name verbatim
  and gives the added occurrence the generation-suffixed
  {base}@hotreload#g1_o{i} name, which also chains forward for reuse.

Classification still rejects lambda-set changes at delta emission; emitting
the added member is Phase C4, documented in the design doc.

Gates: component HotReload 138/138, service HotReload 366/366.
Phase C4 sub-slice 1: writer + emitter support for ADDED TypeDef rows,
mirroring the Roslyn reference delta for "method gains its first capturing
lambda" (new display class). Reference recorded with a compiler-level
Compilation.EmitDifference harness (hot_reload_poc/src/csharp_enc_reference,
Roslyn 5.9.0) because the hotreload-utils workspace tool needs a net11-only
toolchain; the IDE pipeline ends in the same API, so the delta shape is
identical. Template captured verbatim in docs/hot-reload-member-additions.md.

Writer (FSharpDeltaMetadataWriter.emitWithTypeDefinitions):
- TypeDefinitionRowInfo / NestedClassRowInfo row models; TypeDef rows write
  FieldList/MethodList as 0 (Roslyn EnC parity - members are linked through
  the AddField/AddMethod EncLog pairs).
- EncLog: new TypeDef rows are plain Default entries placed before every
  AddField/AddMethod pair that names them as the parent; NestedClass rows
  are plain Default entries trailing the log. Both appear in EncMap.
- Serializer/SRM shadow writer/parity comparer extended for both tables.

Emitter (IlxDeltaEmitter):
- A fresh-compile type with the C3 allocator's generation-suffix marker
  ({base}@hotreload#g{N}_o{i}) and no baseline TypeDef token allocates the
  next delta TypeDef row; its fields/methods (including instance capture
  fields and the .ctor/Invoke pair) register as added members parented to
  the NEW row. Extends is remapped (TypeRef/TypeDef); TypeSpec base types
  rely on baseline passthrough and fail closed past the baseline table, as
  do generic closure classes (GenericParam rows unsupported) - both gaps
  documented.
- Added type tokens chain into the next-generation baseline TypeTokens and
  are reported in UpdatedTypeTokens (Roslyn ChangedTypes parity).

Tests: exact-EncLog writer test mirroring the C# template (service), plus
emitter tests for a hand-constructed nested closure type incl. mdv
validation (component). Component HotReload 140/140, service HotReload
367/367.
Phase C4 sub-slice 2: the payoff slice. A member that gains a lambda now
produces an applicable delta carrying the new closure class.

Classification (TypedTreeDiff): Added-only lambda sets become method-body
edits when the runtime advertises NewTypeDefinition+AddMethodToExistingType,
otherwise NotSupportedByRuntime naming the missing capability (C# parity).
Removed-only sets are allowed at Baseline capabilities (deleted lambda
bodies just become unreachable; the baseline closure class stays unused).
Capture-set changes stay rude.

Emission: the delta compile's fresh rewrite contains the C3 allocator's
{base}@hotreload#g{N}_o{i} closure class; the emitter selects it as an added
TypeDef (sub-slice 1 machinery), and generation-suffixed names are excluded
from basic-name alias buckets (they never alias a baseline closure). The
type token chains into the next-generation baseline, so gen N+1 body-edits
the added lambda in place.

Wiring fixes the runtime test forced:

- checker.StartHotReloadSession rebuilds the baseline from disk, which
  cannot carry EncClosureNames (CDI blobs have no name slots); it now
  carries the tables over from the in-process capture session it replaces
  when the MVID matches. Without this every watch-flow delta compile fell
  back to sequence-replay naming.
- MemberRef/TypeSpec token passthrough is now content-validated: an added
  lambda shifts the fresh compile's reference-row order, and positional
  passthrough silently swapped ListModule.Filter/Map MethodSpec bindings
  (BadImageFormatException at invoke). The baseline snapshots MemberRef row
  contents and TypeSpec blobs (ILBaselineReader, which also gained the
  missing InterfaceImpl row size - assemblies with interface impls had all
  later table offsets misread); the remapper validates positional reuse,
  falls back to content search, then appends (MemberRef) or fails closed
  (TypeSpec). Appended MemberRef rows chain forward for later generations.
- remapTypeSpecBlobWith: TypeSpec blobs are a bare Type (II.23.2.14), not a
  calling-convention-prefixed signature.
- The AsyncStateMachineAttribute heuristic now requires a MoveNext method,
  so closure classes sharing the {method}@hotreload naming no longer pick
  up a spurious attribute.

Runtime evidence (ApplyUpdate succeeds for added lambda creating a new
closure class): gen1 adds a third lambda in front of two survivors -> delta
EncLog carries the new TypeDef row + AddField/AddMethod pairs + NestedClass,
ApplyUpdate accepts, probe observes the new behavior; gen2 body-edits the
ADDED lambda -> method updates only, no new TypeDef rows.

Updated classification tests to the new semantics (added-without-capability
is NotSupportedByRuntime naming NewTypeDefinition; removed-only is a body
edit; async closure-chain shape changes follow the same gating).

Gates: component HotReload 141/141, service HotReload 367/367,
EmittedIL 1212/1212 (3 skipped). Both design docs updated.
Phase C4 sub-slice 3:

- Negative runtime gate: a capability-less session rejects an added lambda
  with NotSupportedByRuntime (FSHRDL016) naming NewTypeDefinition, before
  any emission.
- Removed-lambda runtime test: removing a lambda applies as a plain set of
  method-body updates (no TypeDef table entries; the baseline closure class
  stays, unused) and the new behavior takes effect - pinning the C#-parity
  allowance enabled in sub-slice 2.
- mdv/EncLog pattern-parity assertions on the real generation-1 delta
  against the recorded C# new-display-class template: the new TypeDef row's
  plain Default entry precedes its Add* entries, every AddField/AddMethod
  parent entry is immediately followed by the member row with the Default
  operation, the NestedClass row trails, and EncMap carries the TypeDef and
  NestedClass rows but never the Add* parent entries.

Gates: component HotReload 143/143, service HotReload 367/367.
… conduit

The C2 PDB writer wiring added the methodCustomDebugInfoRows option to
ilwritepdb.fsi; record the intentional drift and refresh the locked hashes.
Phase B2: instance fields added to existing CLASSES are emitted with the
same (TypeDef, AddField) + (Field, Default) EncLog pairing as the B1b
static path, validated against a recorded Roslyn EmitDifference reference
(csharp_enc_reference field_add scenario) and applied at runtime.

Classification: compareEntities now diffs the field segment structurally;
a pure field addition on a TFSharpClass is gated on the negotiated
AddInstanceFieldToExistingType/AddStaticFieldToExistingType capabilities
(RudeEditKind.NotSupportedByRuntime names the missing one) instead of
reporting TypeLayoutChange. Struct/record/union/enum layouts stay rude
permanently (runtime restriction, C# identical), enforced fail-closed in
the emitter via the fresh TypeDef's IsStructOrEnum.

Constructor pairing: a mutable class let binding folds its initializer
into the primary ctor, whose binding diffs as a plain MethodBody update;
ctor return-type identity is now void (the IL truth) so .ctor edits
resolve against baseline method tokens. [<DefaultValue>] val mutable
needs no pairing: the entity surfaces as a TypeDefinition edit and the
delta is a pure Field-row append - the metadata writer's empty-delta
short-circuit now keys on row payload, not method updates alone.

Runtime tests assert C# EnC semantics: existing instances read zeroed
values for the added field, new instances run the updated ctor and see
the initializer, and state survives multi-generation chains.
NatElkins added 10 commits June 19, 2026 12:36
Process.Start returns 'Process | null'; the if/isNull form did not narrow
the nullability onto the 'use proc' binding, so every proc member access
was an FS3261 nullness-as-error on the .NET Core build. Match on the
result so the non-null arm narrows to 'Process'.
Bring the hot reload sources into fantomas compliance so the
CheckCodeFormatting CI leg passes. Formatting only; no behavior change.
Drop transitional/war-story/follow-up framing the repo NoBloat rule asks
to keep out of the diff, while preserving the why:
- DeltaBuilder: drop 'For now' from the unmatched-file rude-edit note.
- IlxDeltaEmitter: drop the 'observed as Bad binary signature' anecdote
  from the baseline-vs-fresh heap-offset comment (the why stays).
- EditAndContinueLanguageService: state the delta-PDB CDI constraint as a
  fact rather than a 'not yet / remaining gap' breadcrumb.
- ClosureNameAllocator: drop the 'no IO / unit-testable' design aside.
- DeltaMetadataSerializer: add an ECMA-335 / Roslyn citation for the
  EnC #~ heap-flags bits (0x20|0x80).
Caches.fs and fsi.fs both open System; restore the unqualified Guid.NewGuid
calls to undo incidental churn unrelated to hot reload.
…dows net10

Assert.Equal<byte[]>(a, b) and Assert.Equal<string>(arr, arr) trip F#
overload resolution against xUnit v3's Assert.Equal overload set on the
Windows net10 build (FS0001 'byte array is not unmanaged' / FS0193
'string array not compatible with string'), though they compile on
macOS/Linux. Switch to the forms already used elsewhere in the same
suite and proven to build on Windows: <byte> for byte arrays and
<string[]> for string arrays. Element-wise comparison is unchanged.
…demo

The sample intentionally consumes the experimental hot reload FCS API
(FSharpHotReloadDelta etc.), so its FS0057 warnings became errors under
CI's global warn-as-error. Suppress 57 in the sample project; the demo
already opts out of the repo Directory.Build props.
Three pre-existing assumptions about a local dev environment failed once
the build started compiling (they had been masked by earlier compile
errors):

- DOTNET_MODIFIABLE_ASSEMBLIES is set via HotReload.runsettings, but the
  MTP (xUnit v3) test host does not honor runsettings EnvironmentVariables,
  so it is unset in CI. ApplyUpdateRunner/Console hard-failwith'd on that;
  they are manual console hosts, so mark them [<Fact(Skip)>] (runtime-apply
  coverage remains in RuntimeIntegrationTests, which already soft-skips).
- runMdvInternal called proc.Start() which throws Win32Exception when the
  'mdv' CLI is not on PATH (CI), rather than returning false; callers
  expected None to mean 'mdv unavailable -> skip'. Catch the exception and
  return None so the mdv tests skip instead of failing.
- The Roslyn baseline snapshot was read via ../../../../tools (one level
  above the repo root); it only resolved locally because of the nested
  working-copy layout. Vendor tools/baselines/roslyn_tables.json into the
  repo and fix the path to ../../../tools so the parity tests run in CI.
'dotnet fantomas .' (the CheckCodeFormatting CI leg) flags the script, but
'dotnet fantomas' refuses to format it ('leads to invalid F# code'), so the
check can never be satisfied by reformatting. Add it to .fantomasignore,
matching the repo's handling of other fantomas-incompatible files.
getMethodTokenByParameterTypes/getMethodTokenBySignature loaded the just-
compiled Library.dll with AssemblyLoadContext.LoadFromAssemblyPath, which
memory-maps and locks the file on Windows (a collectible ALC's unload is
async, so the lock outlives the helper). The tests then recompile the same
Library.dll and fail with 'Could not open file for writing'. Read the bytes
and LoadFromStream instead, so no file handle is held. (Unix never locked,
so this only failed on the Windows coreclr legs.)
On Windows the repo checks out .fs as CRLF (* text=auto), which broke two
classes of test:
- ArchitectureGuardTests reads compiler source files and asserts on
  '\n'-anchored substrings; normalize CRLF->LF after reading.
- RuntimeIntegrationTests embeds source snippets as string literals and runs
  multi-line String.Replace on them (e.g. reordering two task awaits); with
  CRLF the '\n' search string never matched, so the edit was a no-op and the
  rude-edit assertion saw NoChanges. Pin the hot reload test files to LF via
  .gitattributes (matching the existing mono/launcher eol=lf precedent).
@WizMe-M

WizMe-M commented Jun 21, 2026

Copy link
Copy Markdown

Hi, I gave a try to hot-reload demo. I followed steps 1-5 and failed. What am I doing wrong?

First of all step 4 says:

Run it:
dotnet watch run --non-interactive
You should see ⌚ F# hot reload session prestarted, then the counter ticking once a second.
But my output was:

Missing `F# hot reload session prestarted`
HotReloadDemo> dotnet watch run --non-interactive
dotnet watch 🔥 Hot reload enabled. For a list of supported edits, see https://aka.ms/dotnet/hot-reload.
dotnet watch 💡 Press Ctrl+R to restart.
Restore complete (0,4s)
    info NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy
  HotReloadDemo net11.0 succeeded (2,7s) → bin\Debug\net11.0\HotReloadDemo.dll

Build succeeded in 3,7s
dotnet watch ⌚ Loading projects ...
dotnet watch ⌚ Loaded 1 project(s) in 0,4s.
dotnet watch ⌚ Waiting for changes
hello (count: 1) <--------------------------- missing 'F# hot reload session prestarted'
Looks like SDK/F#-compiler with hot-reload wasn't built.

After that I've tried to modify Program.fs and got expected result (TLDR: not works):

TLDR: not works
HotReloadDemo> dotnet watch run --non-interactive
dotnet watch 🔥 Hot reload enabled. For a list of supported edits, see https://aka.ms/dotnet/hot-reload.
dotnet watch 💡 Press Ctrl+R to restart.
Restore complete (0,4s)
    info NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy
  HotReloadDemo net11.0 succeeded (2,7s) → bin\Debug\net11.0\HotReloadDemo.dll

Build succeeded in 3,7s
dotnet watch ⌚ Loading projects ...
dotnet watch ⌚ Loaded 1 project(s) in 0,4s.
dotnet watch ⌚ Waiting for changes
hello (count: 1)
...
hello (count: 32)
dotnet watch ⌚ File updated: .\Program.fs
hello (count: 33)
...
hello (count: 56)
dotnet watch ⌚ File updated: .\Program.fs
hello (count: 57)
...
hello (count: 68)
dotnet watch 🔄 Restart requested. <------------------- ctrl+R
hello (count: 69)
hello (count: 70)
hello (count: 71)
dotnet watch ⌚ [HotReloadDemo (net11.0)] Exited
dotnet watch 🔄 Restarting.
Restore complete (0,5s)
    info NETSDK1057: You are using a preview version of .NET. See: https://aka.ms/dotnet-support-policy
  HotReloadDemo net11.0 succeeded (0,2s) → bin\Debug\net11.0\HotReloadDemo.dll

Build succeeded in 1,3s
dotnet watch ⌚ Loading projects ...
dotnet watch ⌚ Loaded 1 project(s) in 0,1s.
dotnet watch ⌚ Waiting for changes
HOT-RELOAD works (count: 1)   <------------------- only after ctrl+R
HOT-RELOAD works (count: 2)

Info:

  • OS: Windows 11 Pro (ran all steps from Powershell)
  • All $env variables were set correcly following manual
  • F#-compiler was built with no errors and warnings
  • SDK was built on third try with no errors and warnings
dotnet --info
HotReloadDemo> dotnet --info
.NET SDK:
 Version:           11.0.100-dev
 Commit:            ab1a0a0dbb
 Workload version:  11.0.100-manifests.844758e0
 MSBuild version:   18.8.0-preview-26277-111+6ca055abb

Runtime Environment:
 OS Name:     Windows
 OS Version:  10.0.26200
 OS Platform: Windows
 RID:         win-x64
 Base Path:   ~\sdk-hotreload\artifacts\bin\redist\Debug\dotnet\sdk\11.0.100-dev\

.NET workloads installed:
 [maui-windows]
   Installation Source: VS 17.14.37328.6
   Manifest Version:    11.0.0-preview.1.26102.3/11.0.100-preview.1
   Manifest Path:       ~\sdk-hotreload\artifacts\bin\redist\Debug\dotnet\sdk-manifests\11.0.100-preview.1\microsoft.net.sdk.maui\11.0.0-preview.1.26102.3\WorkloadManifest.json
   Install Type:        FileBased

 [maccatalyst]
   Installation Source: VS 17.14.37328.6
   Manifest Version:    26.2.11310-net11-p1/11.0.100-preview.1
   Manifest Path:       ~\sdk-hotreload\artifacts\bin\redist\Debug\dotnet\sdk-manifests\11.0.100-preview.1\microsoft.net.sdk.maccatalyst\26.2.11310-net11-p1\WorkloadManifest.json
   Install Type:        FileBased

 [ios]
   Installation Source: VS 17.14.37328.6
   Manifest Version:    26.2.11310-net11-p1/11.0.100-preview.1
   Manifest Path:       ~\sdk-hotreload\artifacts\bin\redist\Debug\dotnet\sdk-manifests\11.0.100-preview.1\microsoft.net.sdk.ios\26.2.11310-net11-p1\WorkloadManifest.json
   Install Type:        FileBased

 [android]
   Installation Source: VS 17.14.37328.6
   Manifest Version:    36.1.99-preview.1.119/11.0.100-preview.1
   Manifest Path:       ~\sdk-hotreload\artifacts\bin\redist\Debug\dotnet\sdk-manifests\11.0.100-preview.1\microsoft.net.sdk.android\36.1.99-preview.1.119\WorkloadManifest.json
   Install Type:        FileBased

Configured to use workload sets when installing new manifests.
No workload sets are installed. Run "dotnet workload restore" to install a workload set.

Host:
  Version:      11.0.0-preview.6.26277.111
  Architecture: x64
  Commit:       6ca055abbe

.NET SDKs installed:
  11.0.100-dev [~\sdk-hotreload\artifacts\bin\redist\Debug\dotnet\sdk]

.NET runtimes installed:
  Microsoft.AspNetCore.App 11.0.0-preview.5.26227.104 [~\sdk-hotreload\artifacts\bin\redist\Debug\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 11.0.0-preview.6.26277.111 [~\sdk-hotreload\artifacts\bin\redist\Debug\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.NETCore.App 6.0.36 [~\sdk-hotreload\artifacts\bin\redist\Debug\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 7.0.20 [~\sdk-hotreload\artifacts\bin\redist\Debug\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 8.0.28 [~\sdk-hotreload\artifacts\bin\redist\Debug\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 9.0.17 [~\sdk-hotreload\artifacts\bin\redist\Debug\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 10.0.9 [~\sdk-hotreload\artifacts\bin\redist\Debug\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 11.0.0-preview.5.26227.104 [~\sdk-hotreload\artifacts\bin\redist\Debug\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 11.0.0-preview.6.26277.111 [~\sdk-hotreload\artifacts\bin\redist\Debug\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.WindowsDesktop.App 11.0.0-preview.5.26227.104 [~\sdk-hotreload\artifacts\bin\redist\Debug\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 11.0.0-preview.6.26277.111 [~\sdk-hotreload\artifacts\bin\redist\Debug\dotnet\shared\Microsoft.WindowsDesktop.App]

Other architectures found:
  x86   [C:\Program Files (x86)\dotnet]
    registered at [HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\x86\InstallLocation]

Environment variables:
  DOTNET_MSBUILD_SDK_RESOLVER_CLI_DIR      [~/sdk-hotreload/artifacts/bin/redist/Debug/dotnet]
  DOTNET_MULTILEVEL_LOOKUP                 [0]
  DOTNET_ROLL_FORWARD_TO_PRERELEASE        [1]
  DOTNET_ROOT                              [~/sdk-hotreload/artifacts/bin/redist/Debug/dotnet]

global.json file:
  Not found

Learn more:
  https://aka.ms/dotnet/info

Download .NET:
  https://aka.ms/dotnet/download

@ijklam

ijklam commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

After spending one or two hours debugging this, I found why this cannot work on Windows. The issue is that on the Windows, when the program is running, the program file will be locked. As a result, the "dotnet build" executed by the dotnet-watch's FSharpHotReloadService fails, cause the hot reload cannot work.

I finally have managed to make it work, done some simple test on it, and found some problems:

  1. It seems working only when editing the content in the method body.
type Greeter() =
    let mutable count = 0

    member _.Message() =
        count <- count + 1  // Modifying this number is OK
        task {
           do! Task.Delay 2000 // Modifying this number does not take effect until restart
        }
        |> _.GetAwaiter().GetResult()
        sprintf "hello (count: %d)" count  // Modifying this string is OK

let greeter = Greeter()

while true do
    printfn "%s" (greeter.Message())   // Modifying this string doesn't take effect, until restart the program
    System.Threading.Thread.Sleep(1000)   // Changing this 1000 to 2000 doesn't take effect, until restart the program
  1. Simply adding a do! ... in a task block will need to restart the program.
    member _.Message() =
        count <- count + 1
        task {
          do! Task.Delay 2000
          do! Task.Delay 2000  // add this line
        }
  1. Adding or removing lines around a task needs restart program.
    member _.Message() =
        count <- count + 1  // remove this line
        task {
          do! Task.Delay 2000
        }

The quickstart told readers to expect an 'F# hot reload session prestarted'
line, but that message is only emitted under DOTNET_WATCH_TRACE_FSHARP_HOTRELOAD,
so a normal run looked broken. Point readers at the counter as the real signal.
NatElkins added a commit to NatElkins/sdk that referenced this pull request Jun 22, 2026
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.
@NatElkins

NatElkins commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

@ijklam got it right. The bridge ran a full dotnet build on every edit, and the last step of that build copies the assembly into bin, which is the file the running app has loaded. Windows keeps that locked while the process is alive, so the copy failed, the build failed with it, and the edit fell back to a restart. macOS and Linux let you replace a file that's mapped into a live process, so it only bit on Windows.

The fix builds just the Compile target instead, so fsc refreshes the intermediate assembly under obj and nothing writes to the loaded bin copy. The session reads the delta from that intermediate assembly now. bin stays put until the next real restart, and all three platforms take the same path.

It's on fsharp-hotreload-watch-v2 (fcf9b49). Only the dotnet-watch side changed, so you just need to pull that branch and rebuild the sdk clone, no need to rebuild the compiler or recopy the FCS dll. I don't have a Windows machine handy at the moment, so if one of you can confirm it works there now that would help a lot.

@WizMe-M the missing F# hot reload session prestarted line was a red herring. It only prints with DOTNET_WATCH_TRACE_FSHARP_HOTRELOAD=1 set, so not seeing it didn't mean anything was broken. I've reworded the quickstart to say that, and to point at the counter instead (it keeps climbing across edits when reloads land, and resets to 1 on a restart).

On the other observations: editing the top level while loop, or adding a do! to a task block, or moving lines around one, are mostly the expected limits rather than the Windows bug. The running loop is the active frame, so it can't be swapped while it's sitting on the stack, and inserting an await into a state machine is a rude edit in C# too. The count and string edits applying in place is exactly the case that should work. If the line-shift-around-a-task case still misbehaves after the rebuild, ping me, that one is worth a second look.

EDIT: Correcting myself on the struck-out line. @ijklam is right, C# hot reload does support adding an await/do! into a state machine, I read the EnC rules wrong. Roslyn treats it as a normal method update (gated on the runtime's AddInstanceFieldToExistingType capability), and only makes it a rude edit when the method is suspended at an active statement at the moment you save. So adding a do! to a task is a genuine parity gap on the F# side today, not an expected limit. I'm going to look into closing it.

@ijklam

ijklam commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

I think "adding or removing lines before a task block needs a restart" is something worth to be solved, if we hope the feature can help us in the routine coding, as there may be many task in a file, to make the program running asynchronously.

inserting an await into a state machine is a rude edit in C# too

By the way, C# hot reload supports this already.

Adds service-level diff tests pinning that adding a step to async/seq/inner
functions is classified differently from task. Unlike task (a struct resumable
state machine, where adding a do! is StateMachineShapeChange), these lower to
closures, so the same structural edit is not a state machine shape change and
(for async and inner functions) is allowed with full capabilities. Documents
where F# already matches C# and isolates the remaining gap to struct resumable
computation expressions.
Add an Edit-and-Continue codegen mode (--test:HotReloadClassStateMachines)
that lowers task/taskSeq/user resumable computation expressions to
reference-type (class) state machines instead of structs, so adding,
removing, or reordering a let!/do!/yield becomes an
AddInstanceFieldToExistingType + method update at runtime rather than a
forbidden value-type re-layout. This mirrors Roslyn's struct->class flip
under EnC, generalized to the resumable-code substrate (the gate lives in
the shared GenStructStateMachine).

- IlxGen: boxity threaded through TemplateReplacement; class type-def
  (parameterless ctor, newobj, reference-based capture init); a byref-this
  bridge (spill 'this' to a byref local via ldarga + ldobj deref before
  field access) so the byref-threaded resumable ABI works on a reference type.
- TypedTreeDiff: under class mode a resumable-member shape change is an
  allowed, capability-gated method/field update rather than a
  StateMachineShapeChange rude edit.
- IlxDeltaEmitter: a fresh instance field on an existing compiler-generated
  class state machine is emitted as an added field
  (AddInstanceFieldToExistingType) instead of being skipped.
- TcGlobals/TcConfig: the flag drives codegen and the classifier consistently
  across the baseline and delta compiles.
- Tests: runtime ApplyUpdate parity tests (add/remove let!, add inside a loop,
  backgroundTask) for the edits Roslyn allows.

Flag-off is byte-identical (mkILValueTy = mkILNamedTy AsValue); the deref and
spill only fire under an active class-state-machine template replacement.
@NatElkins NatElkins changed the title F# hot reload: Edit-and-Continue delta emission behind --enable:hotreloaddeltas F# hot reload: Edit-and-Continue delta emission behind --test:HotReloadDeltas Jun 23, 2026
symbolChanges.RudeEdits
|> RudeEditDiagnostics.ofRudeEdits
|> List.map (fun diagnostic -> $"{diagnostic.Id}: {diagnostic.Message}")
|> String.concat Environment.NewLine

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.

The rude reason is computed as structured {Id; Message; Kind} and then flattened to a string here, carried as UnsupportedEdit of string, and the bridge maps it to a 4-state enum logged at Debug only — so it never flows through the ENC diagnostic codes channel dotnet-watch already uses for C# (ReportRudeEdits / ReportCompilationDiagnostics). Emitting ENC diagnostic codes (id + severity + message) instead would surface the reason and let you warn on the silent cases: editing a running while/main/module-init applies with no effect and no message today, and the compiler already identifies that code (.cctor in <StartupCode$…>). Roslyn shows ENC0118 Warning: Changing 'top-level code' might not take effect until restart. There is also no equivalent of BaseTypeOrInterfaceUpdate (the entity hash skips base class/interfaces).

Related code:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, and confirmed end-to-end: the structured {Id;Message;Kind} is flattened to UnsupportedEdit of string, the SDK bridge string-splits it back into a 4-state enum logged at Debug, so the reason never reaches the ENC diagnostic-codes channel C# uses (ReportRudeEdits/ReportCompilationDiagnostics). This is a cross-repo design change (FCS + SDK), not a one-liner. Proposed staging: (1) FCS-local — carry {Id;Severity;Message} through FSharpHotReloadError instead of a pre-joined blob, and have the bridge promote known FSHRDL* ids to a warning (surfacing the silent while/main/module-init cases); (2) full alignment — emit ENC-style codes through the same TransientDiagnostics channel, settling the id range with the SDK/Roslyn team (couples with the FSComp.txt thread). Leaving open to track.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Following up on the channel question and the "emit ENC diagnostic codes" point, with a recommendation and what we're implementing. We researched how these IDs are actually consumed downstream (SDK + Roslyn source, plus the wider ecosystem); the short version is that the ID namespace is largely a red herring and the real win is the channel.

Findings:

  • F# rude edits don't travel through Roslyn's diagnostic channel at all. F# projects aren't in the Roslyn Solution, so the bridge returns a plain FSharpManagedUpdateResult.Message string, not a Roslyn Diagnostic. The only two places dotnet-watch keys off an ID string (CompilationHandler.cs, both literal diagnostic.Id == "ENC0118": skip-as-persistent, and the entry-point "Press Ctrl+R to restart" message) run only on the Roslyn updates object that F# cannot populate. So emitting ENC0118 from F# today reaches none of that handling; it would be inert text in a Debug log.
  • The ID isn't user-visible in the dotnet-watch surface anyway: the F# reason is logged at Debug only, and normal output shows the generic "Restart is needed" message. ENC codes have no per-code help links (the descriptors set no helpLinkUri) and aren't individually documented; the place ENC codes are visible (the VS debugger Error List) is the C# path, not dotnet-watch.
  • No external tooling we could find keys off these ID strings. Rude-edit diagnostics also aren't user-suppressible via NoWarn / editorconfig (they're EnC-engine diagnostics, controlled by dotnet-watch settings), so "reuse ENC for suppression" doesn't apply either.
  • ENC is a Roslyn-owned prefix whose numbers are bound to Roslyn's RudeEditKind enum, and the published guidance (roslyn#40351, "Choosing diagnostic IDs") is explicit that a prefix should be owned by a single party and that others shouldn't squat short first-party prefixes. So reusing ENC numbers we don't control is a future-compat / semantic-collision risk with no offsetting tooling or UX benefit at the current seam.

Recommendation: keep the F#-owned FSHRDL prefix (consistent with F# already owning FSxxxx), and spend the effort on the actual gap, which is this channel: stop flattening the structured {Id; Severity; Message} into a string, carry it through the API, and have our own bridge surface the reason (and warn on the silent "might not take effect" cases) instead of dropping it at Debug. All of that is in code we own (FCS plus our own dotnet-watch bridge); none of it needs Roslyn's ENC strings. We can even special-case the entry-point / no-effect message in our own bridge to match the C# UX without ENC0118.

Implementing the channel work now, keeping FSHRDL. We're isolating the ID-namespace choice to a single mapping point and a separate commit, so if you'd rather align with the Roslyn ENC codes it's a clean swap of that one piece rather than a rewrite. Leaving this open pending your call on the ID question.

Comment thread src/Compiler/HotReload/RudeEditDiagnostics.fs
Comment thread src/Compiler/HotReload/EditAndContinueLanguageService.fs Outdated
Comment thread src/Compiler/FSComp.txt
2025,fscAssemblyWildcardAndDeterminism,"An %s specified version '%s', but this value is a wildcard, and you have requested a deterministic build, these are in conflict."
2026,fscHotReloadRequiresDebugInfo,"Hot reload delta capture (--enable:hotreloaddeltas) requires debug symbols (--debug+). Add '--debug+' or remove '--debug-' from the compilation options."
2027,fscHotReloadIncompatibleWithOptimization,"Hot reload delta capture (--enable:hotreloaddeltas) is incompatible with optimizations (--optimize+). Add '--optimize-' or remove '--optimize+' from the compilation options."
hotReloadAdditionNotSupportedByRuntime,"Adding '%s' requires the runtime capability '%s', which the current runtime does not support."

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.

These hotReload* messages are the natural home for the rude edits worth adding next, aligned to ENC diagnostic codes so F# flows through dotnet-watch's existing reporting.
Now: UpdateMightNotHaveAnyEffect (ENC0118, the silent top-level/module-init edit), BaseTypeOrInterfaceUpdate (the entity hash skips base class/interfaces), InternalError (surface an emit crash instead of a silent restart).
Later: ChangingCapturedVariableScope, TypeKindUpdate, EnumUnderlyingTypeUpdate, Changing(Constraints/TypeParameters).
Adopt the ENC diagnostic codes' shape + severity; settle the id range with the SDK/Roslyn team.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Tracking with the ENC-channel thread above — these ENC-aligned hotReload* messages (UpdateMightNotHaveAnyEffect/ENC0118, InternalError, …) only become useful once the rude reason actually flows through a diagnostic channel, and the id range needs agreeing with the SDK/Roslyn team. Note the base-type/interface case is already caught as a rude TypeLayoutChange after the representation-hash fix, so a dedicated BaseTypeOrInterfaceUpdate message is optional polish. Leaving open as a follow-up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Cross-referencing the channel thread (on EditAndContinueLanguageService.fs): on "settle the id range," our recommendation is to keep the F#-owned FSHRDL prefix rather than emit ENC numbers. We checked how the IDs are consumed and found no tooling or UX benefit to ENC at the current seam (the code string isn't shown in dotnet-watch output, nothing external keys off it, and these diagnostics aren't user-suppressible), while ENC is a Roslyn-owned, enum-bound namespace (per roslyn#40351) that we'd be squatting.

These messages only pay off once the structured reason actually reaches the user, so we're landing the channel work first (carry {Id; Severity; Message} instead of flattening to a string) and keeping the ID-namespace choice isolated in its own commit, so aligning with the Roslyn ENC codes later stays a clean swap if you prefer it. Leaving this open.

Comment thread src/Compiler/Driver/CompilerConfig.fs
…capability lists

Two runtime ApplyUpdate tests:
- Adding a do! Task.Yield() to a task under class state machines. Unlike the
  existing class-SM tests (which await Task.FromResult and complete
  synchronously), this drives the real AwaitUnsafeOnCompleted/resume path on a
  patched state machine.
- A method-body edit around an unchanged task state machine, guarding
  occurrence-stable closure identity under a surrounding edit end to end.

Capability-list consolidation across the hot reload test suites:
- Add EditAndContinueCapabilities.All / AllNames as the single source of truth
  for the full capability set, listed once next to the EditAndContinueCapability
  cases. Replace the five hand-copied full-set lists (four component
  fullCapabilities, one service allCapabilities) with references to it.
- Trim the per-test capability sets to the minimal each edit requires, with
  per-file named constants where a pattern repeats. This documents each edit's
  real capability contract and exercises the gating path, instead of every
  positive test passing the full set. Gating, always-rude, and characterization
  tests keep the full set deliberately.

Component HotReload 230/0, service HotReload 412/0.
… identity

The resumable-code/trait shape digest rendered builder-call type
instantiations through TType.ToString() (tyToString), whose depth-limited
LimitedToString(4) collapses a deeply-solved typar to the literal "True".
That made the digest non-injective: a `task` whose return type is `int`
both sides could render Bind<int,int,int> before an edit and
Bind<int,True,True> after, producing a false StateMachineShapeChange
(FSHRDL013) rude edit.

Render the digest through tryTypeIdentityFromTType -- the same injective
runtime-identity encoder the capture path already stores -- threading the
method's typar->ordinal map into collectLoweredShapeInfo and
traitConstraintShapeDigest, with a structured formatter and a display-string
fallback only for types the encoder cannot represent. Update the architecture
guard for the new traitConstraintShapeDigest signature.

Addresses review feedback on dotnet#19941.
FSharpEditAndContinueLanguageService.UpdateActiveStatements had no callers:
the live path is SetActiveStatements (FSharpHotReloadSession.SetActiveStatements
-> SetSessionActiveStatements -> editAndContinueService.SetActiveStatements).
Remove the dead member and its now-orphaned HotReloadSessionStore.UpdateActiveStatements
backing, whose only caller was the dead forwarder.

Addresses review feedback on dotnet#19941.
…error channel

Rude-edit reasons were flattened to a single string at the point of failure
(UnsupportedEdit of string), discarding the per-edit Id, Severity and symbol
before they reached the public API and the dotnet-watch bridge. Carry them
structurally instead:

* RudeEditDiagnostic gains a Severity (FSharpDiagnosticSeverity). Every kind is
  Error today; severityOf is the single place to introduce a non-blocking
  Warning later (e.g. a "might not take effect" edit).
* HotReloadError.UnsupportedEdit and the public FSharpHotReloadError.UnsupportedEdit
  now carry a list of structured diagnostics. A new public FSharpHotReloadRudeEdit
  record exposes Id, Severity, Message and SymbolName.
* The active-statement, deleted-symbol, mapping-error and emit-exception paths
  wrap their ad-hoc reason via RudeEditDiagnostics.unsupported so every error
  still carries an id.

The id namespace is unchanged and still owned solely by
RudeEditDiagnostics.diagnosticId, so the channel itself is id-agnostic.

Addresses review feedback on dotnet#19941.
…he swap point

Document, at RudeEditDiagnostics.diagnosticId (the single place the rude-edit id
namespace is decided), why F# keeps its own FSHRDL* codes rather than emitting
Roslyn's ENC* codes, and note the closest ENC analogs. Kept as a separate commit
from the channel work so aligning with the ENC codes later is an isolated change.

Addresses review feedback on dotnet#19941.
NatElkins added a commit to NatElkins/sdk that referenced this pull request Jun 23, 2026
…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.
Make trying F# hot reload easier:
- Add docs/hot-reload-setup.sh (one shot: build the compiler, clone + build the
  F#-aware SDK, sync them) and docs/hot-reload-sync-fcs.sh (the FCS -> redist sync).
- Build only the SDK redist project rather than the whole repo, which is faster and
  side-steps unrelated test-only build breaks.
- Source the SDK's eng/dogfood.sh to run the locally built CLI instead of hand-setting
  DOTNET_ROOT/PATH.
- Add a "Fast path (one command)" section to the quickstart and fold the manual FCS
  copy and env setup into the scripts above.
The redist-only build (--projects redist.csproj) does a shallow restore that fails on
a fresh clone (NETSDK1004 on a transitive project reference). Restore the whole repo
first via a full build (which stops at a few unrelated broken test projects, expected),
then build only the redist. Verified end to end by the prebuilt-SDK CI on linux.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

6 participants