Skip to content

Hot reload#1

Closed
NatElkins wants to merge 443 commits into
masterfrom
hot-reload
Closed

Hot reload#1
NatElkins wants to merge 443 commits into
masterfrom
hot-reload

Conversation

@NatElkins

@NatElkins NatElkins commented Nov 26, 2025

Copy link
Copy Markdown
Owner

F# Hot Reload (Edit-and-Continue) Implementation

This PR introduces hot reload infrastructure for F# on top of .NET's MetadataUpdater.ApplyUpdate pipeline, with Roslyn-parity-oriented metadata behavior and multi-generation delta chaining.

Status Snapshot (2026-02-07)

  • PR: NatElkins/fsharp#1
  • Branch head: hot-reload@c454aeb
  • Scope has evolved significantly since the original PR text; this description reflects the current state.

Verified in this branch

  • ./.dotnet/dotnet build FSharp.sln -c Debug -v minimal
  • ./.dotnet/dotnet test tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj -c Debug --no-build --filter FullyQualifiedName~HotReload -v minimal ✅ (258 passed)
  • ./.dotnet/dotnet test tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj -c Debug --no-build --filter FullyQualifiedName~HotReload -v minimal ✅ (101 passed)

What This PR Adds

  • Hot reload session lifecycle in FSharpChecker:
    • StartHotReloadSession
    • EmitHotReloadDelta
    • EndHotReloadSession
    • HotReloadSessionActive
    • HotReloadCapabilities
  • Typed-tree diff + rude-edit classification pipeline for deciding delta vs rebuild.
  • Baseline capture (FSharpEmitBaseline) and generation chaining (EncId/EncBaseId) across multiple edits.
  • Delta emitters for metadata/IL/PDB with EncLog/EncMap tracking.
  • Runtime integration path exercised by component tests and demo tooling.
  • In-repo demo app (tests/projects/HotReloadDemo/HotReloadDemoApp) and smoke script (tests/scripts/hot-reload-demo-smoke.sh).

Recent Architecture/Parity Direction (late 2025)

  • Significant SRM boundary reduction/refactoring in hot reload paths:
    • byte-based baseline metadata readers
    • unified handle/index abstractions (BinaryConstants/delta token helpers)
    • PDB parity tests and shared reader/writer utilities
  • Roslyn parity fixes for StandAloneSig row handling and metadata sizing/encoding behaviors.
  • Method additions enabled with Roslyn-parity restrictions (c454aeb).

Supported vs Unsupported (Current)

Supported

  • Method-body edits with metadata/IL/PDB delta emission.
  • Multi-generation sessions.
  • Runtime apply scenarios validated by component tests (when runtime prerequisites are set).
  • Limited method additions under emitter restrictions (no incompatible type-layout changes).

Still rejected / rebuild required

  • Structural edits that change type layout/signature semantics beyond supported boundaries.
  • Edits that introduce unsupported metadata/token mapping cases (reported as UnsupportedEdit/rude edits).

Runtime Apply Capability Model

  • Capability flag is intentionally conservative:
    • RuntimeApply is only advertised when the runtime supports it and FSHARP_HOTRELOAD_ENABLE_RUNTIME_APPLY=1 is set.
  • Demo smoke supports both modes:
    • default: emit + validate deltas without runtime apply
    • opt-in: runtime apply path via HOTRELOAD_SMOKE_RUNTIME_APPLY=1

Remaining Follow-ups (Not Claimed Done Here)

  • Broaden structural edit coverage (especially synthesized/complex scenarios) while preserving Roslyn parity.
  • Improve telemetry/diagnostics surfacing for hosts.
  • Wire official dotnet watch integration path.
  • Continue tightening mdv + runtime parity checks as the final acceptance bar.

Key Files

  • Service/session API: src/Compiler/Service/service.fs, src/Compiler/HotReload/EditAndContinueLanguageService.fs
  • Delta emission: src/Compiler/CodeGen/IlxDeltaEmitter.fs, src/Compiler/CodeGen/FSharpDeltaMetadataWriter.fs, src/Compiler/CodeGen/DeltaMetadataSerializer.fs
  • Baseline/capabilities: src/Compiler/CodeGen/HotReloadBaseline.fs, src/Compiler/HotReload/HotReloadCapabilities.fs
  • Tests:
    • tests/FSharp.Compiler.Service.Tests/HotReload/*
    • tests/FSharp.Compiler.ComponentTests/HotReload/*

Complete closure-matrix item dotnet#8 by introducing an SRM shadow writer and parity gate over the existing delta row model.

- Added DeltaMetadataSrmWriter and wired compare/output flags in FSharpDeltaMetadataWriter.

- Enabled SRM compare mode in check-hotreload-metadata-parity.sh.

- Hardened SRM writer to allocate heap handles via MetadataBuilder (no baseline offset reuse).

- Updated closure matrix status/evidence and validation snapshot.

Validation:

- ./.dotnet/dotnet build FSharp.sln -c Debug -v minimal

- ./.dotnet/dotnet test tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj -c Debug --no-build --filter FullyQualifiedName~HotReload -v minimal (328 passed)

- ./.dotnet/dotnet test tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj -c Debug --no-build --filter FullyQualifiedName~HotReload -v minimal (110 passed)

- ./tests/scripts/check-hotreload-metadata-parity.sh (9 + 28 passed)

Roslyn reference commit/date: 511a87e0653 (2025-10-29).
@NatElkins

Copy link
Copy Markdown
Owner Author

Superseded: this described the original hot-reload prototype branch (443 commits, Nov 2025 base). The implementation was replanted onto current main as hot-reload-v2 with substantially expanded scope — see #3 (full implementation overview) and #2 (first slice of the upstream stack). The old branch remains for reference.

@NatElkins NatElkins closed this Jun 12, 2026
NatElkins added a commit that referenced this pull request Jul 1, 2026
…naming

The delta emitter's fail-closed guard for synthesized types with no baseline
counterpart only recognized the stable '@hotreload' naming scheme. External and
flag-off builds name closures with the fresh-fsc '@<line>' scheme (for example
'Pipe #1 stage #3 at line 16@16' from debug pipe desugaring), so an added
pipe-stage closure skipped the guard, fell through to a baseline token-map lookup
known to be invalid, and crashed EmitDelta with an ilwrite InternalError surfaced
as DeltaEmissionFailed. dotnet-watch treats UnsupportedEdit as a graceful
rebuild-and-restart but DeltaEmissionFailed as an error, so the crash broke the
watch experience for a common edit.

Broaden the guard to IsCompilerGeneratedName, covering both naming schemes.
Legitimately added lambdas are unaffected: generation-suffixed added closures
resolve through the earlier added-TypeDef path, and added user types resolve
through addedUserTypeNames before the guard.

Verified with a full external-build repro (multi-module app, pipe stage inserted
before existing stages): EmitDelta now returns the FSHRDL099 closure-chain rude
edit instead of crashing. HotReload component suite 229 passed, service suite
413 passed. A new disk-started flag-off contract test pins the rejection shape
for this topology; with a flag-on baseline the same insertion remains supported
via CDI-reconstructed occurrence keys.
NatElkins added a commit that referenced this pull request Jul 2, 2026
Two synthesized-closure families could not be aligned across compiles after a
line-adding edit, so such edits always fell back to restart on closure-dense
real-world files (reproduced on Giraffe's endpoint list, failing identically on
the external and in-process paths):
- debug-pipe closures embed the source line in the basic name itself
  ('Pipe #1 stage #2 at line 28'), so a line shift changes the name and the
  fresh type has no baseline counterpart;
- value-binding list closures use sequential ordinals (endpoints@hotreload-N)
  allocated in encounter order, so shifts make one fresh name match several
  baseline candidates.

Add a last-resort positional pairing to the delta emitter's synthesized-type
match ladder, consulted only after exact and path-normalized matching fail or
tie. Groups are keyed by enclosing type plus a line-normalized basic name; the
normalizer recognizes only the known generated forms (pipe labels, @hotreload
ordinals, legacy @line ordinals) and refuses everything else, including
occurrence-keyed names, which already match exactly. Pairing requires equal
group counts, distinct ordinals on both sides, an unused baseline token, and a
structural shape match per pair (generic arity, base type, interfaces, field
type multiset, method name/arity set, with self-references normalized), and
produces a bijection; any failure keeps today's fail-closed rude edit, so a
structural change such as adding a pipe stage or list lambda still requires a
rebuild rather than risking a mispair, which would be silent corruption.
Baseline synthesized-type shapes are derived from the module at session start
and chained for delta-added types; no persisted format changes.

This is the recovery layer: it makes line-shift edits on these families apply
in place when the replayed names drift. A follow-up addresses the drift at its
source by keying the replay name map on line-normalized identity so matched
closures keep their generation-0 names, Roslyn's model; exact matching then
succeeds without inference and this pairing remains a guarded fallback.

Component acceptance: in-process and disk-started line-shifted pipe closures
and ordinal-shifted value-list closures each emit with exactly one user-authored
updated method; count-mismatch cases stay UnsupportedEdit. Service suite 423
passed, component 234 passed (2 expected manual-host skips).
NatElkins added a commit that referenced this pull request Jul 2, 2026
Replay of compiler-generated closure names was keyed by the raw basic name, and
debug-pipe closures embed the source line in the basic name itself ('Pipe #1
stage #2 at line 28'), so any line-adding edit changed the bucket key, missed
the recorded generation-0 allocation, and produced fresh names the baseline
could not match. Value-binding list closures shared a bucket whose sequential
ordinals depended on encounter order and drifted similarly. Both families then
depended on the positional-pairing recovery layer or fell back to restart.

Key replay buckets by the line-normalized basic name and keep the original
generation-0 full names as the values, so a matched closure whose code moves
from line 28 to line 30 is handed back its line-28 birth name. This is Roslyn's
Edit and Continue model: synthesized-member identity is established at first
allocation and replayed exactly, making downstream matching exact rather than
inferred. The normalization grammar is extracted to one shared home in
GeneratedNames and reused by the map, the baseline snapshot collector, the
symbol matcher, and the delta emitter's pairing layer, which now acts purely as
a recovery net behind exact matching.

Snapshot compatibility is handled at load: raw legacy keys are normalized,
duplicate buckets merge, replay buckets canonicalize by ordinal, and gapped
buckets recover the unambiguous raw birth basic name from recorded entries
rather than synthesizing a name that would lose the original line text (a
normalized-key fallback remains for genuinely ambiguous recovery). The
ICompilerGeneratedNameMap surface and the persisted Map<string, string[]>
snapshot shape are unchanged; behavior without an installed map is untouched.

Acceptance: the flag-on in-process session test decodes TypeDef names from the
baseline and fresh assemblies after a line-adding edit and asserts exact name
equality for the unchanged closure families. Service HotReload suite: 426
passed, 0 failed. Component suite: 234 passed, 0 failed, 2 expected skips.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants