F# hot reload: Edit-and-Continue delta emission behind --enable:hotreloaddeltas#19941
F# hot reload: Edit-and-Continue delta emission behind --enable:hotreloaddeltas#19941NatElkins wants to merge 101 commits into
Conversation
…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.
…he session ProjectConfig eagerly probed the filesystem for non-source command-line inputs (resources, key files, ...) and mixed their paths/mtimes into the snapshot signature hash, so every FCS consumer paid constructor-time I/O per snapshot and saw TransparentCompiler cache keys vary with on-disk timestamps even when hot reload was not in use. FSharpProjectSnapshot.fs is back to main byte-for-byte and the TrackedInputsOnDisk surface-area entry is gone. The detection moves to where staleness matters: the hot reload session snapshots tracked inputs (HotReload/TrackedInputs.fs) when a project baseline is captured and compares them before each emit. A tracked input edited without a rebuild now fails the stale-build-output check explicitly (previously the signature hash only forced a redundant re-typecheck and the emit silently produced a delta missing the change); the staged state commits and discards in lockstep with the pending update.
The EnC handle/operation types added to ilbinary.fs duplicate the ILDeltaHandles module and were never exposed by ilbinary.fsi, so nothing could reference them. Surfaced while extracting the foundations PR.
❗ Release notes requiredYou can open this PR in browser to add release notes: open in github.dev
Warning No PR link found in some release notes, please consider adding it.
|
|
Looks like it requires rebase/conficts fix |
| // Graph type-checking is deliberately left as configured: lambda occurrence keys and | ||
| // typed-tree emission order depend on the file order of the compilation, not on the | ||
| // order files are CHECKED in, so TypeCheckingMode.Graph cannot perturb captured output. | ||
| if tcConfigB.emitCaptureArtifacts then |
There was a problem hiding this comment.
This only pins the capture compile. The replay compile (--enable:hotreloadhook) still runs IlxGen in parallel, and GetOrAddName hands names out in call order, so the replayed names can come out in a different order than the sequential baseline and the delta points at the wrong tokens. No crash, just a silently wrong delta. Pin both:
| if tcConfigB.emitCaptureArtifacts then | |
| if tcConfigB.compilerEmitHook.IsSome then |
compilerEmitHook is Some for capture and hook, None otherwise, so the normal path is untouched.
| /// --enable:hotreloaddeltas hook) pay one None check per generated name instead of a | ||
| /// ConditionalWeakTable probe and lock. | ||
| let getCompilerGeneratedNameMapAccessor (owner: obj) : unit -> ICompilerGeneratedNameMap option = | ||
| let mutable resolvedHolder = ValueNone |
There was a problem hiding this comment.
resolvedHolder is a two-field ValueOption, shared by all three generators (one accessor at CompilerGlobalState.fs:70) and read from parallel IlxGen threads with no sync. A torn read can see the tag as ValueSome while the ref is still null, then NRE on .TryGet(). This is on the normal compile path, not just hot reload. volatile won't help a multi-field struct, so resolve it once up front:
let getCompilerGeneratedNameMapAccessor (owner: obj) : unit -> ICompilerGeneratedNameMap option =
let holder = getHolder owner
fun () -> holder.TryGet()There was a problem hiding this comment.
While here: getHolder uses holders.GetValue(...), which inserts/allocates a NameMapHolder even when nothing was installed — the sibling ClosureNameAllocationState.tryGetHolder uses TryGetValue to avoid exactly that. Reading through TryGetValue (insert only on setCompilerGeneratedNameMap) also lets the accessor drop the cached resolvedHolder entirely, fixing this and the torn read above in one go.
| let mutable bytesCache: byte[] option = None | ||
| let mutable offsetsCache: int[] option = None | ||
|
|
||
| let encodeCompressedUnsigned value = |
There was a problem hiding this comment.
encodeCompressedUnsigned is unused, so FS1182, and that's warn-as-error here, so ./build.sh -c Debug fails on it. Same for rowElementGuid / addStringOption / addGuidValue below, and tryGetOutputPathFromProjectOptions in service.fs:921. CI is red on these plus the FS3261 nullness warnings and fantomas.
| moduleId | ||
| encId | ||
| encBaseId | ||
| let useSrmMetadataWriter = shouldUseSrmMetadataWriter () |
There was a problem hiding this comment.
Two metadata writers here: this hand-written one, and the SRM one behind FSHARP_HOTRELOAD_USE_SRM_TABLES (off by default). The SRM path builds from a bare MetadataBuilder() (DeltaMetadataSrmWriter.fs:146) with no baseline heap offsets, so it'd be wrong for any delta that grows a heap, and the parity check skips heap layout so it won't catch that. I'd pick one: finish SRM (heap continuation, real parity, gen2/gen3 tests) and drop the ~1.9k LOC hand-written writer, or drop the SRM shadow.
| let ilxgenGlobalNng = NiceNameGenerator () | ||
| let ilxgenGlobalNng = NiceNameGenerator(getCompilerGeneratedNameMap) | ||
|
|
||
| member _.NiceNameGenerator = globalNng |
There was a problem hiding this comment.
The rule that every generated name goes through the hot-reload wrappers is only checked by tests/scripts/check-ilxgen-name-path.sh, and nothing in CI runs it. If these generators were private and the only way to get a name was a wrapper member (freshIlxName / freshCoreName already exist in IlxGen.fs:52), the compiler would enforce it instead of a grep.
| @@ -0,0 +1,50 @@ | |||
| module internal FSharp.Compiler.CompilerGeneratedNameMapState | |||
There was a problem hiding this comment.
Small thing: the files under Generated/ are hand-written, but the folder name reads like codegen output. They'd fit better next to CompilerGlobalState in TypedTree/.
| /// Flags describing an active statement, aggregated across the threads that own it. | ||
| /// Mirrors <c>Microsoft.CodeAnalysis.Contracts.EditAndContinue.ActiveStatementFlags</c>. | ||
| /// </summary> | ||
| type FSharpActiveStatementFlags = |
There was a problem hiding this comment.
Public, but not [<Experimental>] like the session API in service.fsi. The feature is still experimental, so this locks in as-is. Mark it experimental or keep it internal.
|
|
||
| let private emissionContextLock = obj () | ||
|
|
||
| let mutable private currentEmissionContext: HotReloadEmissionContext option = None |
There was a problem hiding this comment.
Latent, and FCS-only (fsc.exe is one project per process, so it can't hit this). currentEmissionContext is one process-wide mutable, set only in FSharpChecker.Compile and read only in the emit hook's driver phases; the primary EmitDelta path doesn't use it. To clash you'd need a host calling FSharpChecker.Compile concurrently for two projects of one session, which I don't think happens today. If it's worth scoping, AsyncLocal is the small option (like buildPhase/diagnosticsLogger), but F# async doesn't flow ExecutionContext exactly like C# await, so I'd confirm with a concurrent-compile test rather than assume. Or thread it through the per-compile tcConfig.compilerEmitHook and skip the question.
| Assert.DoesNotContain("clearAmbientCompilerEmitHook", source) | ||
|
|
||
| [<Fact>] | ||
| let ``checker compile injects explicit hook-only argument for active hot reload sessions`` () = |
There was a problem hiding this comment.
Nothing checks that the replay compile pins sequential codegen (the wrong-delta thing in fsc.fs:537). If that fix pulls the pin into a small applyHotReloadDeterminismPins, this is a cheap guard, red today and green after:
[<Fact>]
let ``hotreloadhook pins sequential codegen`` () =
let b = getArbitraryTcConfigBuilder()
ParseCompilerOptions(ignore, GetCoreFscCompilerOptions b, [ "--enable:hotreloadhook" ])
applyHotReloadDeterminismPins b
Assert.False(b.parallelIlxGen)
Assert.Equal(OptimizationProcessingMode.Sequential, b.optSettings.processingMode)| failwithf "Unexpected synthesized name '%s' for basic name '%s'." name basicName | ||
|
|
||
| [<Fact>] | ||
| let ``concurrent GetOrAddName calls allocate unique sequential ordinals`` () = |
There was a problem hiding this comment.
This file hits GetOrAddName but never the name-map accessor. A guard for the torn read in CompilerGeneratedNameMapState.fs:32:
[<Fact>]
let ``accessor is safe under concurrent first use`` () =
let accessor = getCompilerGeneratedNameMapAccessor (obj ())
let errors = System.Collections.Concurrent.ConcurrentBag<exn>()
runConcurrently 1000 (fun _ ->
try Assert.True((accessor ()).IsNone)
with e -> errors.Add e)
Assert.Empty(errors)| let sb = StringBuilder() | ||
| sb.Append("kind:").Append(tycon.TypeOrMeasureKind.ToString()) |> ignore | ||
|
|
||
| match tycon.TypeReprInfo with |
There was a problem hiding this comment.
This hashes a type from its TypeReprInfo (kind + fields) but not its base type or interfaces — tcaug_super / ImmediateInterfaceTypesOfFSharpTycon are never read anywhere in the diff. So a base-class or interface change makes no RepresentationHash change and isn't caught as rude:
inherit A()→inherit B(): the ctor body changes and goes through as an allowedMethodBodyedit, butTypeDef.ExtendsstaysA→ invalid delta (InvalidProgram/TypeLoad on apply).- adding a marker interface (no members): no field/member change at all → zero edits, silently dropped, no rude diagnostic. (The existing test passes only because it adds an interface with a member.)
Fold them into the hash so these become a rude TypeLayoutChange:
sb.Append("|super:").Append(match tycon.TypeContents.tcaug_super with Some t -> tyToString denv t | None -> "") |> ignore
for i in tycon.ImmediateInterfaceTypesOfFSharpTycon |> List.map (tyToString denv) |> List.sort do
sb.Append("|intf:").Append(i) |> ignore| if shouldTracePdb () then | ||
| printfn "[hotreload-pdb] source handle nil for delta token 0x%08x (source token=0x%08x)" token sourceToken | ||
| else | ||
| let methodRow = MetadataTokens.GetRowNumber sourceHandle |
There was a problem hiding this comment.
The metadata delta emits updated methods at their baseline row (IlxDeltaEmitter.fs:758), but the PDB delta here takes the fresh row (GetRowNumber sourceHandle) and builds the EncMap from it. When an edit also adds a method (or any generation ≥2 after an add), the rows shift and the two disagree, so ApplyUpdate attaches the new sequence points to the wrong method or rejects the PDB delta — i.e. breakpoints/stepping break after that reload. The gen-1 identity-map PdbTests don't exercise it. Fix: compute methodRow/EncMap from the baseline token, like the metadata writer.
| // capture naming against) another owner's stale captures, while consecutive captures with | ||
| // NO checker creation in between (one logical host) still chain. Session entities are | ||
| // unaffected: they own private stores and reconstruct baselines from disk artifacts. | ||
| do FSharp.Compiler.HotReloadState.clearSessionState () |
There was a problem hiding this comment.
This runs in the FSharpChecker primary constructor, so every FSharpChecker.Create locks and wipes the process-global capture store. A host that creates a second checker (IDE, test runs) then silently discards the first's hot-reload capture state. Don't clear process-global state from a per-instance ctor — scope it to the session.
…ings Removes five unused private bindings that tripped FS1182 under the warn-as-error build (encodeCompressedUnsigned, rowElementGuid, addStringOption, addGuidValue in DeltaMetadataTables.fs; tryGetOutputPathFromProjectOptions in service.fs) and resolves the FS3261 nullness / FS3391 implicit-conversion / FS3390 XML-doc warnings that were keeping CI red: - byteArrayComparer.Equals narrows nullable args via a match instead of box/isNull so the body sees non-null byte[] - isEmpty relies on its non-null byte[] parameter - tryGetOutputPathFromCommandLineOptions drops a dead null case on a non-null string - IlxDeltaEmitter uses an explicit EntityHandle.op_Implicit conversion - ClosureNameAllocator escapes '<=' in its XML doc comment The remaining FS3261 warnings live in DeltaMetadataSrmWriter.fs, removed in the follow-up that drops the SRM shadow writer.
There were two metadata-delta writers: the proven hand-written one (FSharpDeltaMetadataWriter + DeltaMetadataTables) and an SRM-based shadow (DeltaMetadataSrmWriter) gated behind FSHARP_HOTRELOAD_USE_SRM_TABLES / FSHARP_HOTRELOAD_COMPARE_SRM_METADATA, off by default. The SRM path built from a bare MetadataBuilder() with no baseline heap-offset continuation, so it would emit wrong offsets for any delta that grows a heap, and its structural parity check deliberately skipped heap layout, so it could not catch that. Finishing it (heap continuation, real parity, gen2/gen3 coverage) is far more work than the hand-written path already delivers as the default. Remove the shadow writer, its env-var toggles and the compare path, and the SRM-compare slice of check-hotreload-metadata-parity.sh. The hand-written serializer is now the sole path; SrmParityTests still round-trips its output through a System.Reflection.Metadata reader.
getCompilerGeneratedNameMapAccessor cached the resolved holder lazily in a two-field 'NameMapHolder voption' that the parallel IlxGen threads read without synchronization. A torn read could see the tag as ValueSome while the reference was still null and then NRE on .TryGet() — and this is the normal (flag-off) compile path, since parallelIlxGen defaults to on. Resolve the holder exactly once when the accessor is built and capture it in the returned closure, so each generated name is a single volatile field read and there is no shared mutable to tear. The holder is created eagerly (GetValue) because the emit hook installs the map later in the compile, through the same owner, after this accessor exists; pre-creating the holder lets that install mutate the captured object. Pure reads now go through TryGetValue and no longer insert a holder (matching the sibling ClosureNameAllocationState). Adds a ThreadSafetyTests guard exercising 1000 concurrent first-use reads of a freshly created accessor.
GeneratedNames.fs, CompilerGeneratedNameMapState.fs and ClosureNameAllocationState.fs are hand-written, but living under src/Compiler/Generated/ made the folder read like codegen output. Move them next to their consumer CompilerGlobalState in TypedTree/. Pure path move: module names are unchanged (namespace-flat) and the <Compile> entries keep their position, so compile order (ahead of SynthesizedTypeMaps and CompilerGlobalState) is preserved.
…ture The determinism pins (deterministic, parallelIlxGen off, sequential optimization) were keyed on tcConfigB.emitCaptureArtifacts, which is set only for the capture compile (--enable:hotreloaddeltas). The replay/hook compile (--enable:hotreloadhook) installs the emit hook and runs IlxGen too, but was left with parallelIlxGen on: GetOrAddName hands synthesized names out in call order, so parallel IlxGen permutes the replayed names relative to the sequential baseline and the delta points at the wrong tokens — a silent, crash-free wrong delta. Extract applyHotReloadDeterminismPins and key it on compilerEmitHook (Some for both capture and replay, None otherwise, leaving the normal path untouched). Expose it via fsc.fsi for a guard test in ArchitectureGuardTests that parses --enable:hotreloadhook and asserts the pins apply.
…n hash snapshotTycon hashed a type from its TypeReprInfo (kind + fields) but never read tcaug_super or ImmediateInterfaceTypesOfFSharpTycon, so two layout changes slipped through as non-rude: - inherit A() -> inherit B(): the ctor body change went through as an allowed MethodBody edit while TypeDef.Extends stayed A, producing an invalid delta (InvalidProgram/TypeLoad on apply); - adding a member-less marker interface: no field/member change at all, so zero edits and a silently dropped layout change. Fold the base type and the sorted interface set into the non-field digest so both surface as a TypeLayoutChange rude edit. Adds regression tests for the base-class change and the marker-interface add (the existing interface test only passed because it added an interface WITH a member).
The session API in service.fsi carries [<Experimental>], but the public active-statement types in ActiveStatements.fs (FSharpActiveStatementFlags and its siblings) did not, so an experimental feature would lock those in as stable. Annotate all ten public types with the same Experimental message. Intra-assembly use does not warn, and the consuming tests already carry #nowarn "57".
The metadata delta re-emits updated methods at their BASELINE row, but the PDB delta built its EncMap from the FRESH compile's MethodDebugInformation row. Once an earlier edit adds a method (or any generation >=2 after an add) the rows shift and the two EncMaps disagree, so ApplyUpdate attaches sequence points to the wrong method or rejects the PDB delta — breakpoints and stepping break after that reload. Record the baseline row (from the baseline-coordinate token) in the EncMap while still reading the debug info at the fresh row, and process methods in baseline-row order so the emitted MethodDebugInformation rows line up 1:1 with the sorted EncMap — matching the metadata writer, whose Method EncMap is also baseline-row ascending. Adds a regression test exercising the baseline!=fresh row skew.
clearSessionState() ran in the FSharpChecker primary constructor, so every FSharpChecker.Create locked and wiped the process-global capture store — a host creating a second checker (IDE, test runs) silently discarded the first's hot-reload capture state, including committed baselines of a still -live checker. Remove the per-ctor wipe. Freshness for a new owner is already provided at capture time (SetBaseline replaces a project's baseline on a fresh capture), and session entities own private stores, so neither the stale-capture nor the consecutive-capture-chaining intent regresses.
…sion-context invariant T-Gro noted two latent items: - The rule that every compiler-generated name flows through the IlxGen wrappers was only enforced by tests/scripts/check-ilxgen-name-path.sh, which no CI job runs. Making the generators private is a cross-module refactor (detupling, inner-lambda lifting and TypedTree are legitimate non-IlxGen consumers), so instead replicate the guard as an xUnit test that asserts the three wrappers exist and that generator access is centralized to exactly their three bodies — so it now runs in CI. - currentEmissionContext is a process-wide mutable that cannot clash today (no host compiles two projects of one session concurrently). Document the single-writer invariant and the per-compile scoping path to take if a concurrent host is ever added, rather than adopting AsyncLocal blindly (F# async does not flow ExecutionContext like C# await).
…e.fs The hot-reload type block prepended to service.fs (the public error/delta/ capability types, the internal FSharpHotReloadService, and the FSharpHotReloadSession entity, ~800 lines) roughly doubled the file. Like FSharpProjectSnapshot and FSharpWorkspace, these belong in their own file. Move them to HotReload/FSharpHotReloadSession.fs (namespace unchanged: FSharp.Compiler.CodeAnalysis), keeping CreateHotReloadSession and the FSharpChecker-scoped wiring helpers on the checker. The types are pure dependency-injection (no FSharpChecker private state), so this is a non-behavioral move. The new file compiles after BackgroundCompiler.fs so the FSharpProjectSnapshot/FSharpCheckProjectResults deps resolve, and the public surface moves from service.fsi onto the .fs declarations.
Run fantomas over the hot-reload sources so the repo formatting check (fantomas . --check) passes — the new/edited compiler files were never formatted, which was part of the red CI T-Gro noted. No behavioral change.
The 'service-owned enc instance' guard asserted on service.fs, but FSharpHotReloadService (which owns editAndContinueService) moved to HotReload/FSharpHotReloadSession.fs. Read the new file for the positive assertion and check the .Instance anti-pattern is absent from both.
…ainst null Two regressions surfaced by the component HotReload runtime-apply suite: - Removing clearSessionState() from the FSharpChecker ctor (the earlier 'scope capture store off ctor' change) broke ce-local-lambda closure-chain alignment: the legacy fsc ambient capture path needs each checker to start from a clean ambient slot, and SetBaseline's per-capture REPLACE does not isolate the chained closure-name recording across the per-scenario checkers the RuntimeIntegration tests create. Restore the reset; a comment records T-Gro's architectural concern and that per-checker ambient stores are the proper (larger) follow-up. Session entities are unaffected either way. - isEmpty dropped its null check when the FS1182/FS3261 cleanup tightened it to blob.Length = 0, but absent CDI rows arrive as null at runtime; guard with isNull (box blob) (FS3261-safe) so empty and null blobs both decode to the empty record.
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 cloneto editing a running F# app — including adding aList.map (fun s -> s.ToUpper())to a live method and watching it apply in place, state preserved. Short version: build this branch, build NatElkins/sdkfsharp-hotreload-watch-v2(a complete .NET CLI with an F#-awaredotnet watch), copy the freshly builtFSharp.Compiler.Service.dllinto the SDK layout, add<OtherFlags>$(OtherFlags) --enable:hotreloaddeltas</OtherFlags>to a console app, anddotnet watch run.What works
async, resume-point-stabletask, and generics[<CLIEvent>]eventsinline/SRTP) degrade to the standard rebuild-and-restart flow with a precise diagnosticIsolation 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:
--enable:hotreloaddeltas(requires--debug+, incompatible with--optimize+). No MSBuild property or SDK behavior changes in this repo.FSharpProjectSnapshot.fsis byte-identical to main (tracked-input staleness lives in the hot reload session layer instead).ICompilerEmitHookinterface with a no-op default. Guard scripts undertests/scripts/(run by the verification gate) enforce that only the intended files consume the hook and that theIlxGenname-generation path and fsi surface stay on their pinned shapes.DOTNET_WATCH_FSHARP_HOTRELOAD=0) that restores stock restart-on-edit behavior.Architecture
FSharpChecker.CreateHotReloadSessionreturns aFSharpHotReloadSessionholding per-project committed snapshots + emit baselines — theDebuggingSession/CommittedSolutionshape from Roslyn, built onFSharpProjectSnapshot(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.AddMethodToExistingType,NewTypeDefinition,GenericUpdateMethod, …). Anything unclassifiable fails closed.EmitDifferencereference deltas, with mdv, and against CoreCLRApplyUpdatein runtime tests.Design documentation (rendered, on this branch)
FSharpHotReloadSession, per-project committed snapshots + baselines onFSharpProjectSnapshot, the FSharpWorkspace relationship, determinism pinsEmitDifferencereference templates (EncLog/EncMap shapes per edit kind) and the F# emission matrix incl. every intentional fail-closed caseEditAndContinueCapabilitiesparity) and per-capability gatingScale and review shape
~85 commits, 154 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.fsMetadataTablerecord→class with anIEncLogWriterseam); total deletions across the branch are 394 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:
IlxGen/name-generation paths — reviewed on its own)Evidence
ApplyUpdate+ invocation, multi-generation chains, cross-process (disk-started) sessions#Stringsheap layout #19732, Determinism: closure type names differ between --parallelcompilation- and --parallelcompilation+ #19928 (open; flag-on compiles are insulated),$(ParallelCompilation)never reaches the Fsc task — targets pass the item@(ParallelCompilation)instead of the property #19935 (why the pin is compiler-level)./tests/scripts/hot-reload-verify.shKnown limitations / future work
Companion PRs