diff --git a/.fantomasignore b/.fantomasignore
index 1b373a01b2e..7637d020993 100644
--- a/.fantomasignore
+++ b/.fantomasignore
@@ -102,6 +102,10 @@ src/Compiler/Facilities/prim-parsing.fsi
src/Compiler/Utilities/TaggedCollections.fsi
src/Compiler/Service/ServiceDeclarationLists.fsi
+# Fantomas limitation: reformatting produces invalid F# (script uses constructs fantomas mis-handles)
+
+tools/hot-reload/compare_roslyn.fsx
+
# Fantomas limitations on signature files in FSharp.Core (to investigate)
src/FSharp.Core/fslib-extra-pervasives.fsi
diff --git a/.gitattributes b/.gitattributes
index 4b84dc7b095..acd73a8e5e2 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -29,6 +29,12 @@ mono/launcher text eol=lf
mono/config.make text eol=lf
targets.make text eol=lf
+# Hot reload tests embed F# source snippets as string literals and run line-anchored
+# string operations ('\n' Replace/Contains) on them; keep these files LF so the snippets
+# behave identically on Windows checkouts.
+tests/FSharp.Compiler.ComponentTests/HotReload/*.fs text eol=lf
+tests/FSharp.Compiler.Service.Tests/HotReload/*.fs text eol=lf
+
*.sh text eol=lf
*.bsl linguist-vendored=true
diff --git a/.gitignore b/.gitignore
index ae438fb2b11..b8fbcc7a499 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,9 @@ BenchmarkDotNet.Artifacts/
/tests/scripts/current
.dotnet/
+# Also ignore .dotnet as a symlink (the trailing-slash pattern above matches directories only,
+# which once let a dev-machine symlink slip into a commit and break every CI checkout).
+.dotnet
.packages/
.tools/
diff --git a/FSharp.slnx b/FSharp.slnx
index 50819fbfb6b..c97fdbce36c 100644
--- a/FSharp.slnx
+++ b/FSharp.slnx
@@ -43,6 +43,11 @@
+
+
+
+
+
diff --git a/docs/debug-emit.md b/docs/debug-emit.md
index 2b0f37391dd..8110057539f 100644
--- a/docs/debug-emit.md
+++ b/docs/debug-emit.md
@@ -26,11 +26,28 @@ Debugging information affects numerous user experiences:
* **Profiling** results
* **Code coverage** results
-Some experiences are un-implemented by F# including:
+Some debugger experiences remain incomplete in F# including:
* **Autos** during debugging
-* **Edit and Continue**
-* **Hot reload**
+* Full IDE/debugger-host integration for **Edit and Continue** and **Hot Reload**. The compiler
+ has experimental metadata/IL/PDB delta support and a session API, but Visual Studio/vsdbg
+ active-statement plumbing is still separate host work.
+
+## Hot reload heap tracing
+
+The hot-reload metadata writer exposes a lightweight tracing hook for capturing heap sizes while iterating on delta emission. Set the environment variable `FSHARP_HOTRELOAD_TRACE_HEAPS=1` before running a targeted test, for example:
+
+```
+FSHARP_HOTRELOAD_TRACE_HEAPS=1 ./.dotnet/dotnet test --project tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj -c Debug --no-build -- --filter-class "*FSharpDeltaMetadataWriterTests*"
+```
+
+Each delta emitted during the run prints a summary such as:
+
+```
+[fsharp-hotreload][heap-summary] baseline:string=1234 blob=2048 guid=256 | delta:string=64 blob=32 guid=0
+```
+
+The new service-level regressions (`property/event/async delta reports baseline heap offsets`) validate that `MetadataDelta.HeapOffsets` mirror the baseline reader before any heap reuse changes are made. Combine those tests with the trace output above to document the current string/blob footprint before adjusting the builders.
## Emitted information
diff --git a/docs/hot-reload-active-statements.md b/docs/hot-reload-active-statements.md
new file mode 100644
index 00000000000..714242b04dc
--- /dev/null
+++ b/docs/hot-reload-active-statements.md
@@ -0,0 +1,192 @@
+# F# Hot Reload: Active Statements & Sequence Point Updates
+
+This document covers the model types, per-edit sequence-point/line-update computation,
+active-statement remapping, and the delta-surface exposure a debugger host consumes.
+Source research: Roslyn main @ June 2026 (`src/Features/Core/Portable/Contracts/EditAndContinue/*`,
+`ActiveStatement.cs`, `ActiveStatementsMap.cs`, `EditSession.cs`,
+`AbstractEditAndContinueAnalyzer.cs` `GetLineEdits`/`AnalyzeUnchangedActiveMemberBodies`).
+
+## Scope
+
+This is the COMPILER-side active-statement machinery: what Roslyn's
+`WatchHotReloadService`/`EmitSolutionUpdate` carries on every update even in watch mode. Full
+debugger-host integration (VS/vsdbg wire-up, `IManagedHotReloadService` plumbing) is out of local
+scope; see "Host integration notes" at the end for what a host has to do.
+
+## Model: Roslyn contract mapping
+
+Public types in `src/Compiler/HotReload/ActiveStatements.fs` (namespace
+`FSharp.Compiler.CodeAnalysis`), mirroring `Microsoft.CodeAnalysis.Contracts.EditAndContinue`
+field-for-field where semantics match:
+
+| F# type | Roslyn contract type | Notes |
+|---|---|---|
+| `FSharpSourceSpan` | `SourceSpan` | Zero-based lines AND columns (one less than the 1-based Portable-PDB sequence-point coordinates). F# PDBs always carry columns, so the `-1 = missing column` convention is not modeled. |
+| `FSharpManagedModuleMethodId` | `ManagedModuleMethodId` + enclosing `ManagedMethodId` | Module MVID + token + 1-based version. Keeping the MVID in the F# record makes MethodDef-token collisions unambiguous across a multi-project session. |
+| `FSharpManagedInstructionId` | `ManagedInstructionId` | Method id + IL offset. |
+| `FSharpActiveStatementFlags` + `FSharpActiveStatementFrameKind` | `ActiveStatementFlags` | The `LeafFrame`/`NonLeafFrame` bits become a closed union (`Leaf \| NonLeaf \| LeafAndNonLeaf`) so "neither leaf nor non-leaf" is unrepresentable; `MethodUpToDate`, `PartiallyExecuted`, `NonUserCode`, `Stale` map to independent booleans (they are genuinely independent). |
+| `FSharpManagedActiveStatementDebugInfo` | `ManagedActiveStatementDebugInfo` | Instruction + PDB document name (option) + span + flags. |
+| `FSharpSourceLineUpdate` | `SourceLineUpdate` | Zero-based old line -> new line. |
+| `FSharpSequencePointUpdates` | `SequencePointUpdates` | Per-PDB-document line updates, sorted by old line; zero-delta entries terminate shifted ranges exactly like Roslyn's. |
+| `FSharpManagedActiveStatementUpdate` | `ManagedActiveStatementUpdate` | Method id BEFORE the change (echoed from the debugger), old IL offset, new span. |
+| `FSharpActiveStatementRemapResult` | — (F#-shaped) | Roslyn's `ManagedHotReloadUpdate.ActiveStatements` carries only remapped statements; F# also reports `MethodUpToDate of FSharpManagedInstructionId` explicitly so hosts/tests can assert untouched methods. |
+
+Delta surface (`FSharpHotReloadDelta`, service.fsi):
+
+- `SequencePointUpdates: FSharpSequencePointUpdates list` — mirrors
+ `ManagedHotReloadUpdate.SequencePoints`.
+- `ActiveStatementUpdates: FSharpActiveStatementRemapResult list` — mirrors
+ `ManagedHotReloadUpdate.ActiveStatements` (plus the explicit `MethodUpToDate` entries).
+- `ManagedHotReloadUpdate.ExceptionRegions` has NO F# counterpart yet (see "Deferred").
+
+## Supplying active statements (API shape)
+
+`FSharpHotReloadSession.SetActiveStatements: FSharpManagedActiveStatementDebugInfo seq -> unit`
+(session-scoped setter; REPLACES the whole set; empty clears).
+
+Rationale: Roslyn's `EmitSolutionUpdate(solution, activeStatementSpanProvider)` PULLS the debugger's
+break state per edit session (`DebuggingSession` queries
+`IManagedHotReloadService.GetActiveStatementsAsync` lazily). FCS has no callback seam into the
+host, so the fetch inverts to a push: the host reports the break state whenever the debugger stops,
+and the next `FSharpHotReloadSession.EmitDelta` consumes it. A session setter (rather than an
+`EmitDelta` parameter) matches the session-scoped lifetime of the break state. The session stores
+the debugger batch once and filters it by module MVID for each project emit.
+
+## Sequence-point updates (line shifts)
+
+The typed-tree diff's hashes are deliberately range-independent (in the lambda occurrence model,
+ranges are diagnostics-only, never identity), so an edit that only MOVES code produces no semantic edits. Confirmed and wired:
+the line updates come from sequence-point comparison alone.
+
+Pipeline (all inside `IlxDeltaEmitter.emitDeltaWithDebugData` + `ActiveStatementAnalysis`):
+
+1. **Committed view** — `FSharpEmitBaseline.SequencePointSnapshots: Map` is the debugger's current view of each method's lines. Seeded from the
+ baseline portable PDB at session start (sibling-read from disk in the checker flow, because
+ `readIlModule` attaches no debug points) and REPLACED wholesale with the fresh compile's points
+ after every committed update. This matches Roslyn diffing line edits against the
+ `CommittedSolution`: each generation's updates are relative to what the debugger last applied,
+ and they compose across generations.
+2. **Fresh view** — decoded from the fresh compile's on-disk PDB (passed down from
+ `FSharpHotReloadSession.EmitDelta` through `EmitDeltaForCompilation`/`EmitDelta`), falling back to the
+ emitter's in-memory PDB for callers that construct modules with debug points. The same bytes
+ feed the emitted PDB delta, which therefore carries real sequence points in the checker flow.
+3. **Per-method classification** (`compareMethodSequencePoints`, every baseline-matched method not
+ already recompiled by the request):
+ - `Identical` — nothing to do (still contributes a zero-delta segment for overlap detection,
+ Roslyn parity).
+ - `UniformLineShift d` — same IL offsets, columns and line extents, every visible point moved
+ by `d` lines: a line-edit segment.
+ - `Different` — anything else (a line split inside a body, column changes, document changes):
+ the method body is RECOMPILED in the delta so its debug information stays accurate. This is
+ the analog of Roslyn's trivia edits forcing a member body update
+ (`AbstractEditAndContinueAnalyzer` reports `requiresUpdate` and converts the member to a
+ semantic edit).
+4. **Segment merge** (`mergeLineShiftSegments`) — verbatim port of Roslyn `GetLineEdits`: sort by
+ (document, old start line); zero-delta reset entries terminate shifted ranges; consecutive
+ equal deltas collapse; segments overlapping a previous segment with a DIFFERENT delta cannot be
+ expressed as line updates and fall back to recompilation of that method ("the debugger does not
+ apply line deltas to recompiled methods").
+
+### Line-shift-only deltas
+
+Previously a line-shift edit emitted `NoChanges` (the debugger's lines went silently stale).
+Now `EmitDeltaForCompilation` always runs the emitter and decides from the artifacts:
+
+- semantic/trivia changes -> normal delta (line updates ride along for the methods that only moved);
+- ONLY line shifts -> a delta carrying ONLY `SequencePointUpdates`: `Metadata`/`IL` empty,
+ `UpdatedMethods` empty, `GenerationId = Guid.Empty`, NO generation consumed. The updated
+ sequence-point view and implementation files are staged as one pending line-only update;
+ `Commit` advances them and `Discard` leaves the committed view unchanged.
+- nothing at all -> `NoChanges`, as before.
+
+**Documented deviation**: Roslyn emits a Module-row-only EnC generation for line-only updates (its
+`EmitDifference` always runs); the F# emitter deliberately skips the no-op `ApplyUpdate` — there is
+nothing for the runtime to apply, hosts just rebind the debugger's lines. If byte-parity with
+Roslyn's generation chain ever matters here, the empty-update emit path is the place to add the
+Module-row-only delta.
+
+Also note: emits with NO semantic edits no longer register unmatched fresh definitions as
+additions — regeneration noise (e.g. a generative type provider re-emitting its members under a
+changed static argument with unchanged consumed IL) previously hid behind the `NoChanges`
+short-circuit and would otherwise materialize into spurious delta rows.
+
+## Active statement remapping
+
+`ActiveStatementAnalysis.remapActiveStatement(s)`, invoked by
+`FSharpEditAndContinueLanguageService.EmitDelta` after the emitter returns and BEFORE any session
+state is staged (a rude outcome blocks the whole update and leaves the session at the previous
+generation, surfacing as `FSharpHotReloadError.UnsupportedEdit`).
+
+Per supplied statement:
+
+- containing method NOT recompiled by the delta -> `MethodUpToDate` (line shifts, if any, are
+ covered by `SequencePointUpdates`; Roslyn likewise leaves these to the line deltas).
+- containing method recompiled -> resolve the statement's IL offset to the last visible committed
+ sequence point at or before it, and map BY ORDINAL to the fresh visible points when both sides
+ have the same visible-point count; the result is
+ `Remapped { Method = (echoed debugger id); ILOffset = (old offset); NewSpan = (fresh span) }`.
+
+Conservative-but-honest rude fallbacks (F# has no Roslyn syntax map — the diff is typed-tree
+based — so ambiguous sequence-point alignment fails TOWARD rude, Roslyn parity for the
+statement-destroying classes `ActiveStatementUpdate`/`DeleteActiveStatement`):
+
+| Condition | Outcome |
+|---|---|
+| visible-point counts differ (statement added/deleted at or around the active statement) | rude |
+| NON-LEAF frame and the aligned span changed by anything other than a pure line shift (same columns + line extent) | rude (the runtime cannot remap a non-topmost frame; LEAF frames may move freely — the debugger remaps them) |
+| statement is `Stale` or not `MethodUpToDate` and its method is updated again | rude (the executing version is older than the committed view; alignment would be a guess) |
+| `PartiallyExecuted` statement in an updated method | rude (Roslyn: partially executed statements can't be edited) |
+| no committed or no fresh sequence points for an updated method (no PDB, lost debug info) | rude |
+| IL offset resolves into a hidden region or before the first sequence point | rude |
+
+Note the ordinal alignment is stricter than Roslyn: adding a statement anywhere in a method with an
+active statement is rude in F# (Roslyn tracks the statement through the syntax map and allows it).
+Loosening this requires source-span tracking of the supplied statement spans against the typed
+tree — see "Deferred".
+
+## Where things live
+
+- `src/Compiler/HotReload/ActiveStatements.fs` — public model + `ActiveStatementAnalysis`
+ (decode/classify/merge/remap).
+- `src/Compiler/CodeGen/HotReloadBaseline.fs` — `FSharpEmitBaseline.SequencePointSnapshots`.
+- `src/Compiler/CodeGen/IlxDeltaEmitter.fs` — `emitDeltaWithDebugData`, in-emitter analysis,
+ trivia-recompile injection, committed-view chaining, `IlxDelta.SequencePointUpdates` /
+ `ChainedSequencePoints` / `ActiveStatementUpdates`.
+- `src/Compiler/HotReload/EditAndContinueLanguageService.fs` — emit-or-NoChanges decision,
+ line-only commit, remap + rude gating.
+- `src/Compiler/HotReload/HotReloadState.fs` — session `ActiveStatements`,
+ `UpdateActiveStatements`, `UpdateCommittedSequencePoints`.
+- `src/Compiler/Service/service.fs(i)` — `FSharpHotReloadDelta.SequencePointUpdates` /
+ `ActiveStatementUpdates`, `FSharpHotReloadSession.SetActiveStatements`, on-disk PDB plumbing.
+- `tests/FSharp.Compiler.Service.Tests/HotReload/ActiveStatementTests.fs` — line-shift (incl.
+ disk-restarted session), remap-to-new-span cross-checked against the delta PDB's sequence-point
+ table, MethodUpToDate, delete-statement rude, non-leaf-edit rude.
+
+## Host integration notes (future watch/debugger wiring)
+
+- **dotnet-watch (no debugger)**: leave the session's active-statement set empty; consume
+ `SequencePointUpdates` to decide that a line-only change needs NO restart and NO `ApplyUpdate`
+ (`Metadata` empty). Watch's `HotReloadService` forwards the updates to the agent unchanged.
+- **Debugger host**: on break, translate the debugger's active statement info
+ (`ICorDebugFunction` token/version + IL offset + PDB span) into
+ `FSharpManagedActiveStatementDebugInfo` and push it; after a successful emit, apply
+ `SequencePointUpdates` (rebind sequence points/breakpoints) and feed
+ `ActiveStatementUpdates`' `Remapped` entries to the debugger's IP remapper; on
+ `UnsupportedEdit` mentioning active statements, surface Roslyn's "restart required" UX.
+- Line updates compose: each delta's `OldLine` refers to the lines as of the PREVIOUS applied
+ update (Roslyn `CommittedSolution` semantics), not to generation 0.
+- The committed view survives process restarts only at generation 0 (decoded from the on-disk
+ PDB); mid-session restore inherits the existing limitation that delta PDBs are not re-read on restore
+ of a mid-chain session.
+
+## Deferred
+
+- `ManagedExceptionRegionUpdate` (exception-region tracking and non-remappable regions) — Roslyn
+ computes these from syntax; F# would derive them from the IL exception-region tables of old vs
+ new bodies.
+- Span-based statement tracking to relax the ordinal-alignment rude edits (statement insertion in
+ a method with an active statement).
+- Module-row-only EnC generation for line-only updates (Roslyn byte-parity).
+- VS/vsdbg `IManagedHotReloadLanguageService` adapter exposing these payloads over the contract
+ types.
diff --git a/docs/hot-reload-architecture.md b/docs/hot-reload-architecture.md
new file mode 100644
index 00000000000..c3ac9c52ab4
--- /dev/null
+++ b/docs/hot-reload-architecture.md
@@ -0,0 +1,148 @@
+# Hot reload architecture: the session entity model
+
+Status: implemented on `hot-reload-v2` (June 2026). This documents the entity model the F#
+hot reload (Edit and Continue) support is built around, the snapshot-contract decision, and
+how the entities map onto Roslyn's.
+
+## The entity model
+
+One `FSharpHotReloadSession` exists per host session (a `dotnet watch` run, a debug session).
+It is created from a checker (`FSharpChecker.CreateHotReloadSession`) and returned as an
+object the host holds and disposes. Sessions are independent instances: a second session does
+not clobber the first, and you cannot emit a delta without a session object that tracks the
+project — illegal states are unrepresentable.
+
+```
+FSharpHotReloadSession (one per watch/debug session; IDisposable)
+├── CommittedProjects: Map
+│ └── CommittedProject = { Snapshot view (typed impl files) // committed diff inputs
+│ Baseline: FSharpEmitBaseline // keyed to loaded Mvid
+│ Generation chain (CurrentGeneration,
+│ PreviousGenerationId, PendingUpdate)
+│ Chained CDI / closure-name /
+│ sequence-point state // rides on the baseline }
+├── Capabilities: EditAndContinueCapabilities (session-wide, updatable)
+├── ActiveStatements: per-statement debug infos (session-wide, host-pushed)
+└── Operations:
+ AddProject : FSharpProjectSnapshot * ?outputPath -> ... (baseline capture)
+ EmitDelta : FSharpProjectSnapshot -> Result (diff vs committed)
+ Commit/Discard: solution-wide (all pending project updates together — Roslyn semantics,
+ prevents cross-project inconsistency)
+ UpdateCapabilities / SetActiveStatements : session-wide
+```
+
+Internally the session owns a `HotReloadSessionStore` instance (`HotReloadState.fs`): a map of
+per-project slots keyed by `HotReloadProjectKey` (the internal mirror of
+`FSharpProjectIdentifier`: `projectFileName * outputFileName`), plus the session-wide
+capability set and active statements. The per-project chained state — EnC method debug
+information (CDI occurrence chains), closure-name tables, committed sequence points — rides on
+`FSharpEmitBaseline`, so keying the baseline per project keys all of it; nothing in the delta
+engine below `EmitDeltaForCompilation` changed.
+
+### Emit / commit lifecycle
+
+`EmitDelta` diffs the fresh snapshot (typed trees + rebuilt output assembly) against the
+project's COMMITTED baseline and stages the result as the project's pending update. After the
+host applies the update (`MetadataUpdater.ApplyUpdate`), `Commit()` advances every pending
+project update atomically — committed baselines, diff inputs (implementation files), and
+generation counters move together. `Discard()` drops all pending updates so the next emit
+re-diffs against the unchanged committed view. This is Roslyn's
+`EmitSolutionUpdate` / `CommitSolutionUpdate` / `DiscardSolutionUpdate` split; partial
+cross-project commits are unrepresentable.
+
+Line-shift-only updates (no metadata/IL, no generation consumed) commit their rebound
+sequence-point view immediately in both flows — there is nothing to pass to `ApplyUpdate`, so
+there is nothing to stage.
+
+## The snapshot contract decision
+
+The session depends on the snapshot CONTRACT, not the workspace container. It takes
+`FSharpProjectSnapshot`s — immutable, content-hash versioned, composable, with recursive
+project-to-project references — and `FSharpWorkspace` remains an optional host adapter that
+manufactures snapshots from file events. If the experimental workspace API shifts, the session
+is untouched; a host with no workspace (Ionide, another LSP server) hands us snapshots some
+other way. This mirrors Roslyn's `ISolutionSnapshotProvider` seam (roslyn #82905): the host
+provides snapshots; the EnC service consumes them. MSBuild stays the authority on project
+discovery and evaluation — F# does not own a project system, and the session does not change
+that.
+
+## Roslyn mapping
+
+| Roslyn (EnC) | F# hot reload | Notes |
+|---|---|---|
+| `DebuggingSession` | `FSharpHotReloadSession` | One per debug/watch session; holds committed views and baselines |
+| `CommittedSolution` | per-project committed snapshot view + `Map` | F# composes recursively per project; no solution-level snapshot type needed for EnC |
+| `_projectBaselines: ProjectId → ProjectBaseline` | `CommittedProjects: HotReloadProjectKey → HotReloadProjectState` (baseline + generation chain) | Keyed by `FSharpProjectIdentifier` (projectFileName * outputFileName) |
+| `EmitBaseline` | `FSharpEmitBaseline` | Carries the chained CDI/closure-name/sequence-point state per project |
+| `EditAndContinueCapabilities` (session-level) | session-wide `EditAndContinueCapabilities` | Updatable mid-session (`UpdateCapabilities`) |
+| `GetActiveStatementsAsync` (debugger pull) | `SetActiveStatements` (host push) | FCS has no callback seam into the host, so the host pushes the break state before emitting |
+| `EmitSolutionUpdate` | `EmitDelta(snapshot)` | Per-project diff against the committed view; result staged as pending |
+| `CommitSolutionUpdate` / `DiscardSolutionUpdate` | `Commit()` / `Discard()` | Solution-wide across all pending project updates |
+| `ISolutionSnapshotProvider` | the `FSharpProjectSnapshot` parameter | Hosts provide snapshots; the session consumes them |
+
+## The default-session compatibility shim — RETIRED
+
+The pre-entity `FSharpChecker` surface (`StartHotReloadSession`, `EmitHotReloadDelta`,
+`EndHotReloadSession`, `UpdateHotReloadCapabilities`, `SetHotReloadActiveStatements`,
+`HotReloadSessionActive`) and the checker-held default session store behind it are gone:
+`CreateHotReloadSession` is the only way to obtain hot reload behaviour from a checker, and
+the process-wide store registration (`HotReloadState.setSessionStore`) was deleted with the
+shim. All FCS test suites and the demo app drive the session-object API. (The sdk reflection
+bridge still needs its move to the session-object shape; that lives outside this repo.)
+
+### Emit-hook ownership after retirement
+
+With no ambient registration, the fsc emit hook resolves its owner per compile:
+
+- **In-process DELTA compiles** (the host rebuilding a session-tracked project through
+ `FSharpChecker.Compile`): each session registers the resolved output path of every project
+ it baselines (`AddProject`) with its owning checker; `Compile` matches a non-capture
+ compile's output path against that registry, arms `--test:HotReloadHook`, and sets a
+ SCOPED EMISSION CONTEXT (`HotReloadState.setCurrentEmissionContext`: the session's store +
+ the project key) around the compile. The hook prefers the scoped context, so the
+ closure-name allocator and synthesized-name replay run against the EMITTING session's
+ chained tables — and the hook never ends a scoped session's state (sessions own their
+ lifecycle; the legacy clear-on-build stays for context-less compiles). The most recently
+ baselined project wins when several live sessions track the same output.
+- **Flag-on BASELINE capture compiles** are session-independent: they read config + typed
+ tree and write artifacts (the deterministic dll/pdb) plus side-channels. Capture compiles
+ never run under a scoped context; the captured baseline is published to the process-local
+ module store in `HotReloadState` (a capture slot serving capture-to-capture name chaining
+ within one host, standalone-fsc validation and unit-level tests), which is never any
+ session's store. Creating a checker resets the slot — the freshness property the retired
+ per-checker store registration used to provide — so one owner's captures never chain
+ against another's. Sessions reconstruct baselines from the on-disk dll + pdb and never
+ read the capture slot.
+- `ClosureNameAllocationState` / compiler-generated-name-map side channels stay keyed by
+ `CompilerGlobalState` via `ConditionalWeakTable` — per-compile by construction, no
+ per-session keying needed.
+
+## Determinism pins for baseline capture
+
+A hot reload baseline must be byte-reproducible: a capture compile of identical source in
+another process (the dotnet-watch topology) has to reproduce the exact row/heap layout the
+running process baselined, or every chained delta resolves against the wrong tokens. The
+per-compile hot reload machinery is deterministic by construction — occurrence keys are
+syntactic, emission walks list-ordered typed trees, and the delta emitter hardcodes
+deterministic output. Normal parallel/sequential codegen determinism is fixed by
+dotnet/fsharp #19929, but hot reload has a stricter baseline/replay contract: the replay map
+must consume generated names in the same order as the captured baseline, or a delta can target
+the wrong metadata rows even when the final non-hot-reload PE would be deterministic.
+
+`--test:HotReloadDeltas` therefore silently pins, at config finalization in
+`Driver/fsc.fs` (`main1`, after all flags are processed):
+
+- `deterministic <- true` — stable MVID/timestamp, deterministic PE emission (upstream MVID
+ determinism, dotnet/fsharp #19801, is already in the base);
+- `parallelIlxGen <- false` — keep hot reload replay-name consumption in the same codegen
+ call order as the captured baseline;
+- `optSettings.processingMode <- Sequential` — keep optimized method bodies feeding codegen
+ in the same order as the captured baseline.
+
+These are silent flag-implies-flag pins, not user-facing errors: msbuild cannot reliably
+switch parallelism off itself (dotnet/fsharp #19935), so the user has no meaningful way to
+choose otherwise, and under the already-required `--debug+ --optimize-` the pins have no
+optimization cost. Graph type-checking (`TypeCheckingMode.Graph`) is deliberately left as
+configured: lambda occurrence keys and typed-tree emission order derive from the
+compilation's file order, not from the order files are checked in, so checking-order
+parallelism cannot perturb captured output.
diff --git a/docs/hot-reload-capabilities.md b/docs/hot-reload-capabilities.md
new file mode 100644
index 00000000000..006d6c0c189
--- /dev/null
+++ b/docs/hot-reload-capabilities.md
@@ -0,0 +1,162 @@
+# Hot Reload Runtime Capability Negotiation
+
+F# hot reload negotiates *runtime edit-and-continue capabilities* the same way Roslyn does:
+the host (for example `dotnet-watch`) collects the capability strings reported by the target
+runtime (`MetadataUpdater.GetCapabilities()`) and passes them to the compiler when a hot
+reload session starts. Edit classification then distinguishes between:
+
+- edits that are **unsupported by F# hot reload** (always rude: virtual-method insertion,
+ signature changes, type-layout changes, ...), and
+- edits that are **valid but cannot be applied by the connected runtime**
+ (`RudeEditKind.NotSupportedByRuntime`, diagnostic id `FSHRDL016`, message names the
+ missing capability).
+
+This mirrors Roslyn's `RudeEditKind.NotSupportedByRuntime` distinction.
+
+## The model
+
+`src/Compiler/Utilities/EditAndContinueCapabilities.fs` (namespace
+`FSharp.Compiler.EditAndContinue`):
+
+- `EditAndContinueCapability` — internal DU with one case per runtime capability word:
+ `Baseline`, `AddMethodToExistingType`, `AddStaticFieldToExistingType`,
+ `AddInstanceFieldToExistingType`, `NewTypeDefinition`, `ChangeCustomAttributes`,
+ `UpdateParameters`, `GenericAddMethodToExistingType`, `GenericUpdateMethod`,
+ `GenericAddFieldToExistingType`, `AddExplicitInterfaceImplementation`, `AddFieldRva`.
+ `Name` returns the exact runtime/Roslyn capability string.
+- `EditAndContinueCapabilities` — immutable wrapper over a `Set`
+ with `Supports`, `IsBaselineOnly`, `CapabilityNames`, `BaselineOnly` and `Parse`.
+
+Capability strings cross the compiler boundary exactly once, in
+`EditAndContinueCapabilities.Parse`. Everything downstream consults the typed model, so
+there are no stringly-typed capability checks in classification or emission.
+
+### Parser semantics (Roslyn parity)
+
+Mirrors `EditAndContinueCapabilitiesParser.Parse` in
+`roslyn/src/Features/Core/Portable/EditAndContinue/EditAndContinueCapabilities.cs`:
+
+- each input string is one capability name; matching is exact and case-sensitive;
+- unknown capability words are **ignored** (forward compatibility with newer runtimes);
+- the aggregate word `AddDefinitionToExistingType` expands to
+ `AddMethodToExistingType + AddStaticFieldToExistingType + AddInstanceFieldToExistingType`.
+
+One deliberate deviation: an F# session always carries at least `Baseline`. `Parse` of an
+empty (or all-unknown) input yields `BaselineOnly`, and `Baseline` is implied whenever any
+other capability is recognized. A capability-less session is unrepresentable; method-body
+updates are never gated behind a missing `Baseline` word.
+
+## Session plumbing
+
+- `FSharpChecker.CreateHotReloadSession(?capabilities: string seq)` creates the independent
+ session entity. When omitted, the session defaults to
+ `EditAndContinueCapabilities.BaselineOnly` (Roslyn-conservative: assume the runtime can only
+ update method bodies).
+- `FSharpHotReloadSession.UpdateCapabilities(capabilities)` reparses and replaces the
+ session-wide set when the target process reports a new runtime topology. Every project added
+ through `AddProject` observes that same set.
+- `EmitDeltaForCompilation` and `FSharpHotReloadSession.EmitDelta` pass the project's session view
+ capabilities into `computeSymbolChanges` → `TypedTreeDiff.diffImplementationFile`.
+
+The `fsc --test:HotReloadDeltas` emit hook (`Driver/HotReloadEmitHook.fs`) does not
+negotiate capabilities and therefore runs baseline-only.
+
+## Classification gating
+
+`src/Compiler/TypedTree/TypedTreeDiff.fs` consults a single seam:
+
+```fsharp
+[]
+type AdditionKind =
+ | Method
+ | InstanceField
+ | StaticField
+
+let capabilityForAddition: AdditionKind -> EditAndContinueCapability
+```
+
+The order of checks for an added declaration follows Roslyn
+(`AbstractEditAndContinueAnalyzer` / `CSharpEditAndContinueAnalyzer`, see uses of
+`EditAndContinueCapabilities.` there): edit-kind rude edits first (virtual, constructor,
+operator, explicit interface, interface member, field), then the runtime-capability check.
+A method addition that passes the rude-edit checks requires
+`capabilityForAddition AdditionKind.Method = AddMethodToExistingType`; if the session does
+not support it, the diff reports `RudeEditKind.NotSupportedByRuntime` with the
+`hotReloadAdditionNotSupportedByRuntime` FSComp message naming the capability.
+
+## Capability gating beyond method additions
+
+Field additions are capability-gated:
+
+- Module-level values (static backing field + accessors) require
+ `AddStaticFieldToExistingType` + `AddMethodToExistingType`.
+- Instance fields on CLASSES (`let mutable` / `[] val mutable` /
+ auto-property backing fields) require `AddInstanceFieldToExistingType`,
+ checked by the entity-level field diff in `compareEntities` (a pure field
+ addition no longer reports `TypeLayoutChange`); per-field staticness selects
+ the static or instance capability.
+- STRUCT (and record/union/enum) field additions stay `TypeLayoutChange`
+ permanently — the runtime cannot re-layout value types (C# identical).
+
+Generic edits are capability-gated (Roslyn parity:
+`AbstractEditAndContinueAnalyzer.InGenericContext`, which walks the symbol chain
+for a generic method arity or a generic containing type):
+
+- BODY EDITS of a member in a generic context (the compiled method has its own
+ generic parameters — including auto-generalized module functions — or is
+ declared in a generic type) require `GenericUpdateMethod`; without it the
+ diff reports `RudeEditKind.NotSupportedByRuntime` with the
+ `hotReloadGenericUpdateNotSupportedByRuntime` FSComp message (FSHRDL016)
+ naming the capability. (Roslyn reports
+ `RudeEditKind.UpdatingGenericNotSupportedByRuntime`.)
+- METHOD ADDITIONS in a generic context additionally require
+ `GenericAddMethodToExistingType` on top of `AddMethodToExistingType`
+ (Roslyn `GetRequiredAddMethodCapabilities`).
+- FIELD ADDITIONS in a generic context (binding-level and the entity-level
+ field diff in `compareEntities`) additionally require
+ `GenericAddFieldToExistingType` (Roslyn `GetRequiredAddFieldCapabilities`).
+
+`InGenericContext` is computed on `BindingSnapshot` from the compiled-form
+typar split (`GetValReprTypeInCompiledForm`, method typars vs enclosing
+typars); erased (measure) typars do not count, so measure-only generic types
+stay gated like non-generic IL. `EntitySnapshot.IsGeneric` mirrors this for
+the entity-level field diff.
+
+Attribute edits are capability-gated: changing the custom
+attributes of an EXISTING member (add/remove/argument change, detected via
+`BindingSnapshot.AttributesDigest`) requires `ChangeCustomAttributes`;
+without it the diff reports `RudeEditKind.NotSupportedByRuntime` with the
+`hotReloadAttributeChangeNotSupportedByRuntime` FSComp message (FSHRDL016)
+naming the capability (Roslyn:
+`RudeEditKind.ChangingAttributesNotSupportedByRuntime`). With the capability
+the edit is an ordinary member update; members whose attribute rows are
+Property/Event-parented (accessors, module values) fail closed — see
+docs/hot-reload-member-additions.md.
+
+Parameter renames are capability-gated: a matched binding whose
+compiled parameter NAMES differ (`BindingSnapshot.ParameterNames` — curried/
+tupled groups flattened, the implicit `this` argument excluded; renaming the
+self identifier is not a parameter rename) requires `UpdateParameters`;
+without it the diff reports `RudeEditKind.NotSupportedByRuntime` with the
+`hotReloadParameterRenameNotSupportedByRuntime` FSComp message (FSHRDL016)
+naming the capability (Roslyn: `RudeEditKind.RenamingNotSupportedByRuntime`).
+With the capability the member re-emits as an ordinary update whose Param
+rows carry the new names. Parameter TYPE changes remain `SignatureChange`
+rude edits.
+
+`NewTypeDefinition` gates added-lambda closure classes and
+USER-DEFINED type additions: adding a class/record/union/struct
+classifies as a `SemanticEditKind.Insert` entity edit when the capability is
+granted (`RudeEditKind.NotSupportedByRuntime` naming it otherwise); other
+representations (interfaces, enums, delegates) stay `DeclarationAdded` rude
+with precise messages. The new type's member bindings ride along with the
+entity edit and are exempt from the existing-type member-addition gates.
+
+## Roslyn references
+
+- `roslyn/src/Features/Core/Portable/EditAndContinue/EditAndContinueCapabilities.cs` —
+ flags + parser (capability set mirrored here).
+- `roslyn/src/Features/Core/Portable/EditAndContinue/AbstractEditAndContinueAnalyzer.cs` —
+ capability-gated classification and `RudeEditKind.NotSupportedByRuntime` reporting.
+- `roslyn/src/Features/CSharp/Portable/EditAndContinue/CSharpEditAndContinueAnalyzer.cs` —
+ language-specific required-capability computation.
diff --git a/docs/hot-reload-closure-mapping.md b/docs/hot-reload-closure-mapping.md
new file mode 100644
index 00000000000..153759d81b4
--- /dev/null
+++ b/docs/hot-reload-closure-mapping.md
@@ -0,0 +1,632 @@
+# F# Hot Reload: Closure & State-Machine Mapping Design
+
+This document describes how F# hot reload gives lambdas, closures, and state machines stable
+identity across edit generations: the typed-tree lambda occurrence model and alignment in
+`TypedTreeDiff`, Roslyn-format EnC CustomDebugInformation (CDI) persistence, occurrence-keyed
+closure name allocation, added-lambda (new closure class) emission with occurrence-derived
+deterministic naming across processes, and state-machine edit support (classification,
+emission backstops, state-map persistence).
+Source research: Roslyn main @ June 2026 (`ClosureConversion.cs`, `EncVariableSlotAllocator.cs`, `EditAndContinueMethodDebugInformation.cs`, `DefinitionMap.cs`, `AbstractEditAndContinueAnalyzer.ReportLambdaAndClosureRudeEdits`).
+
+## Problem
+
+F# rejects any member-body edit whose lowered shape changes (`LambdaShapeChange`, `StateMachineShapeChange`,
+`QueryExpressionShapeChange`). Because idiomatic F# is closure/CE-heavy, this reduces hot reload to
+string/arithmetic tweaks. Roslyn supports editing lambda-containing methods via stable synthesized-member
+identity persisted across generations.
+
+## How Roslyn does it (summary of the machinery we must match)
+
+1. **Stable identity**: every lambda and closure scope gets a `DebugId(ordinal, generation)` at lowering
+ time. Names embed the id (`b__0#1_0#1`), so matching never depends on fragile name text.
+2. **Persistence**: three Portable-PDB CustomDebugInformation blobs per method carry the mapping state:
+ *EnC Local Slot Map*, *EnC Lambda and Closure Map*, *EnC State Machine State Map* (compressed-int
+ formats documented in `EditAndContinueMethodDebugInformation`). Generation N+1 reads generation N's
+ blobs via `EmitBaseline.DebugInformationProvider`.
+3. **Slot allocator seam**: lowering consults `EncVariableSlotAllocator.TryGetPreviousLambda/Closure/
+ HoistedLocalSlotIndex/StateMachineState`. Hit → reuse the previous id/slot/state; miss → allocate a
+ fresh id suffixed with the current generation (new synthesized member, emitted via
+ `AddMethodToExistingType` / `NewTypeDefinition` / `AddInstanceFieldToExistingType`).
+4. **Compatibility checks**: matched closures validate parent id + capture set; matched lambdas validate
+ closure ordinal. Incompatible → the previous member body is replaced with a throwing stub
+ (`HotReloadException`, `DeletedMethodBody.GetIL`) and new members are synthesized fresh.
+5. **What stays rude in C#** (our parity ceiling, not floor): renaming a captured variable, changing a
+ captured variable's type or scope, changing lambda parameters/return/type-params (runtime rude edits),
+ inserting an await/yield mid-sequence (`ChangingStateMachineShape`).
+
+## F#-shaped design
+
+F# differs from C# in three load-bearing ways, and the design leans into them instead of transplanting
+Roslyn types verbatim:
+
+| Roslyn concept | F# realization | Rationale |
+|---|---|---|
+| `DebugId(ordinal, generation)` | Same shape, new `LambdaDebugId` in CodeGen | Direct port; embed in synthesized names via the existing replayable name map (`CompilerGeneratedNameMapState` already gives deterministic `@hotreload` names — extend entries with `(ordinal, generation)`). |
+| Syntax offsets + SyntaxMap | **Typed-tree occurrence alignment** | F#'s diff is typed-tree based (no Roslyn-style SyntaxMap). Lambdas are identified by a per-member *occurrence path*: traversal index within the member body + parent-lambda chain + structural digest. Old/new occurrence sequences are aligned with a longest-common-subsequence pass — equivalent tolerance to Roslyn's syntax-offset matching for insertions/deletions/reorderings, expressed on the tree we actually diff. Ranges (which shift with line edits) are recorded for diagnostics only, never identity. See "The lambda occurrence model as implemented" below. |
+| `EncVariableSlotAllocator` | `ClosureSlotAllocator` interface threaded into IlxGen/EraseClosures when compiling a delta | Same seam: lowering asks "did the previous generation have this occurrence?" and reuses class/method/field identity on hit. |
+| PDB CDI blobs | **Emit the identical Roslyn CDI formats** in `ilwritepdb.fs`; read them in `ILBaselineReader`/`PortablePdbSnapshot` | The blob formats are language-agnostic and debugger-recognized; byte-parity here keeps future debugger EnC (active-statement and sequence-point tracking) viable and lets mdv/Roslyn tooling inspect our maps. In-memory session state remains the fast path; the PDB is the durable source of truth (survives session restarts). |
+| Display-class field matching | Closure class field reuse keyed by captured value identity (`CaptureIdentity`: logical name + `RuntimeTypeIdentity`) | Captures map to fields of the F# closure class; compatibility = same name and `RuntimeTypeIdentity`. |
+| `DeletedMethodBody`/`HotReloadException` | Same: synthesize the exception type on first need (`NewTypeDefinition`), emit throwing stubs for incompatible/removed lambda methods | Required for safe behavior when stale closure instances call removed code. |
+
+### The lambda occurrence model as implemented (`TypedTreeDiff.fs`)
+
+The occurrence model, alignment, and classification live in `src/Compiler/TypedTree/TypedTreeDiff.fs`
+(`LambdaOccurrenceId`, `LambdaOccurrence`, `CaptureIdentity`, `CaptureSetChange`, `LambdaEdit`,
+`MemberLambdaEdits`; `TypedTreeDiffResult.LambdaEdits` carries the per-member payload). Decisions made
+while implementing, where they refine the draft above:
+
+- **Occurrence granularity**: consecutive curried lambdas (`fun x -> fun y -> ...`, looking through
+ `Expr.DebugPoint`/`Expr.Link`) form ONE occurrence with `CurriedArity = n`, matching how IlxGen forms
+ a single closure class for a curried chain. A non-lambda expression between the lambdas (e.g. a `let`)
+ ends the group, so an "extra closure layer" is two occurrences.
+- **Member-top stripping**: the member's own parameter lambdas are not closures. Extraction strips the
+ binding's top-level `Expr.TyLambda`s and exactly `ValReprInfo.NumCurriedArgs` lambda groups before
+ walking the body — the same arity decision IlxGen replays when forming closures.
+- **Structural digest**: the draft digest was (curried arity, capture identity list, return type
+ identity). The implementation additionally includes per-group **parameter type identities**. Without
+ them, a parameter-shape change (e.g. `fun (x, y) -> ...` → `fun (x, y, z) -> ...`) would silently
+ align two occurrences that the legacy arity digest treated as a shape change — the model must never
+ be more permissive than the digest it replaces for signature-shaped changes.
+- **Captures**: free locals of the occurrence expression (`freeInExpr CollectTyparsAndLocals`), filtered
+ to values captured from enclosing scopes: `IsCompiledAsTopLevel`/`IsMemberOrModuleBinding` values are
+ accessed via static paths in IL and are never closure fields. Self identifiers are normalized
+ (`this`/`base`) so renaming the F# self identifier is not a capture rename. Capture lists are
+ deduplicated and ordered by (name, type identity) so set comparison is deterministic.
+- **Alignment**: two LCS passes. Pass 1 matches on the full structural digest (parent chain, curried
+ arity, parameter types, return identity, captures): pairs here are compatible (BodyEdited when the
+ body hash differs). Pass 2 re-aligns the leftovers on the shape-only digest (sans captures): pairs
+ here are either reordered survivors (capture sets still equal) or capture-incompatible
+ (`CaptureSetChanged` with a precise sub-kind). Remaining occurrences are `Added`/`Removed`.
+ Two *identical* reordered occurrences align positionally by construction — intentional, since
+ indistinguishable closures are interchangeable.
+- **Capture sub-kinds** (C# parity names appear verbatim in the rude-edit messages):
+ same name/different type → `TypeChanged` (*ChangingCapturedVariableType*); same type/different name →
+ `Renamed` (*RenamingCapturedVariable*); a value that stops being captured by one occurrence and starts
+ being captured by another in the same member → `ScopeChanged` on both pairs
+ (*ChangingCapturedVariableScope*, detected by a cross-occurrence post-pass); leftovers →
+ `CaptureAdded`/`CaptureRemoved`. `CaptureAdded` messages note the edit may become applicable
+ via `AddInstanceFieldToExistingType` once capture-field additions are supported. Note: scope moves
+ that leave every capture set unchanged are invisible to typed-tree capture identity; they surface
+ (and are handled) at closure name allocation.
+- **Parent-chain identity**: the parent chain stores enclosing-occurrence ordinals from the occurrence's
+ own snapshot. Inserting a lambda *before* an outer lambda therefore shifts the ordinals of every
+ later occurrence's parent chain and conservatively breaks alignment of their descendants
+ (Removed+Added → rude). An accepted conservatism; revisit with hierarchical matching if test
+ evidence demands.
+- **Exclusions (legacy digest path)**: members containing quotations (`Expr.Quote` — stays on the
+ query digest path entirely), object expressions (`Expr.Obj` — separate IlxGen closure path), local
+ type functions (`Expr.TyLambda` in the body), or any capture/parameter/return type without a
+ computable `RuntimeTypeIdentity` are NOT occurrence-modelled; they keep the legacy whole-body
+ `LambdaShapeDigest` comparison and rude-edit behavior, byte-for-byte. Loop/`try` operands
+ (`TOp.While`/`TOp.IntegerForLoop`/`TOp.TryWith`/`TOp.TryFinally`) are wrapped by the checker in
+ delay lambdas (`mkDummyLambda`) that IlxGen always eliminates; extraction walks through them without
+ creating occurrences.
+- **Deliberate tightening vs the legacy digest**: capture renames/type changes and lambda return/parameter
+ identity changes were invisible to the arity-only digest and were previously (unsoundly) classified as
+ plain MethodBody edits. They are now rude with C#-parity kinds. Pure body edits within an unchanged
+ lambda set remain plain MethodBody edits, carrying the structured `BodyEdited` payload for the
+ delta emitter.
+- **Scope of the model**: the occurrence model by itself does not relax the edit surface — edits that
+ change the lambda set produce `LambdaShapeChange` rude edits unless the added-lambda emission path
+ (below) covers them. The structured messages (counts + ordinals) and the `LambdaEdits` payload are
+ what that emitter consumes.
+
+### EnC CDI blob encoder/decoder as implemented (`EncMethodDebugInformation.fs`)
+
+`src/Compiler/CodeGen/EncMethodDebugInformation.fs` replicates Roslyn's three EnC CDI blob formats
+byte for byte (`roslyn/src/Compilers/Core/Portable/Emit/EditAndContinueMethodDebugInformation.cs`:
+`SerializeLocalSlots`/`SerializeLambdaMap`/`SerializeStateMachineStates` and the `Uncompress*`
+readers), including the syntax-offset-baseline optimization and the slot-map kind/ordinal byte
+packing (0xFF baseline marker, 0x00 temp, bit 7 = has-ordinal, low bits = kind + 1). The CDI kind
+GUIDs (from `roslyn/src/Dependencies/CodeAnalysis.Debugging/PortableCustomDebugInfoKinds.cs`):
+
+| CDI | Kind GUID |
+|---|---|
+| EnC Local Slot Map | `755F52A8-91C5-45BE-B4B8-209571E552BD` |
+| EnC Lambda and Closure Map | `A643004C-0240-496F-A783-30D64F4979DE` |
+| EnC State Machine State Map | `8B78CD68-2EDE-420B-980B-E15884B8AAA3` |
+
+**Occurrence-key encoding**: the blob slots Roslyn fills with *syntax offsets* carry, in F#,
+deterministic ints packed from the occurrence ordinal chain (root-first enclosing-occurrence
+ordinals ending with the occurrence's own ordinal). Packing is 16-bit segments: a depth-1 chain
+`[o]` is the key `o` itself (0..0xFFFF); a depth-2 chain `[p; o]` packs as `((p + 1) <<< 16) ||| o`,
+the +1 bias keeping depth-1 and depth-2 key spaces disjoint. Encoding **fails closed**
+(`tryEncodeOccurrenceKey` returns `None`, callers must treat the occurrence as unmappable/rude):
+chains deeper than 2, ordinals past 0xFFFF, or packed keys past the ECMA-335 compressed-integer
+budget (0x1FFFFFFD, leaving room for the -1 baseline adjustment). Keys never truncate.
+
+**Debugger-interop caveat**: the blob *format* is identical to Roslyn's, so mdv and Roslyn
+tooling can decode our maps structurally — but the integers are occurrence keys, not source syntax
+offsets. A debugger (or any consumer) that interprets them as positions into F# source will get
+nonsense; future active-statement/sequence-point debugger work must either teach the debugger the
+F# key semantics or emit a parallel source-offset map. Cross-validation: the component test builds
+a C# library with the repo SDK, decodes the Roslyn-emitted CDI rows with our decoder, and
+re-encodes them byte-identically.
+
+### Baseline CDI emission as implemented
+
+When `--test:HotReloadDeltas` is on, the fsc emit path (`fsc.fs` `main6`, right where the
+baseline-capture hook already receives `optimizedImpls`) computes per-method occurrence data from
+the same optimized typed tree the baseline snapshot stores:
+
+- **Conduit**: `TypedTreeDiff.collectMemberLambdaOccurrences` (public occurrence extraction over a
+ `CheckedImplFile`; returns every member binding, with an empty occurrence list for members the
+ model cannot represent) → `EncMethodDebugInformation.computeMethodCustomDebugInfoRows`
+ (occurrences → serialized blobs) → new `methodCustomDebugInfoRows: Map` field on the IL writer `options` → `generatePortablePdb` →
+ CDI rows on `MethodDef` parents in the portable PDB. Flag-off builds pass the empty map
+ everywhere; the writer path is a strict no-op then (EmittedIL gate: byte-identical output).
+- **Keying (fail closed twice)**: rows are keyed by IL method name (`SymbolId.CompiledName`).
+ The producer drops members without a compiled name and any compiled name claimed by more than
+ one member binding in the assembly (overloads, same-named members on different types); the PDB
+ writer independently drops any name that does not identify exactly one `MethodDef` row in the
+ module. A map is therefore never attached to the wrong method; ambiguous methods simply carry
+ no lambda map (later generations must treat their lambdas as unmappable). Token-precise keying
+ (via the IlxGen token maps) can replace name keying if the exclusions bite.
+- **What is emitted**: only the *EnC Lambda and Closure Map*, for methods with at least one
+ occurrence whose key chains are encodable; `MethodOrdinal` stays `UndefinedMethodOrdinal` (-1,
+ Roslyn semantics for "no partial-method disambiguation needed"). One closure scope per
+ occurrence and lambda *i* references closure *i*: IlxGen lowers every lambda occurrence
+ (curried group) to its own closure class, so there is no shared display-class scope to model;
+ refinement to `Static`/`ThisOnly` closure ordinals is a lowering-side concern. If *any*
+ occurrence key of a method fails to encode (chain depth > 2, ordinal > 0xFFFF), the whole
+ method gets no map — a partial map could silently mismatch occurrences.
+- **What is omitted (documented exclusions)**: the *EnC Local Slot Map* (the lowered local slot
+ layout is an IlxGen emission artifact, not trivially derivable from the typed tree — omitted
+ rather than guessed). The *EnC State Machine State Map* is emitted separately (see "EnC State
+ Machine State Map persistence" below). Members the occurrence model excludes (quotations,
+ object expressions, local type functions, uncomputable type identities) and module-level
+ values whose initializers run in startup code (no method row matches their compiled name)
+ carry no map.
+- **Tests**: `PdbCdiEmissionTests` compiles a library with the flag (2 lambdas, one nested),
+ opens the PDB with System.Reflection.Metadata, decodes the row with the F# decoder and checks
+ occurrence keys `[0]`/`[0; 1]` plus closure ordinals; the flag-off compile of the same source
+ must contain zero EnC CDI rows.
+
+### Baseline CDI read & in-memory chaining as implemented
+
+When a hot reload session baseline is captured, the EnC CDI rows of the baseline portable PDB
+are decoded into `FSharpEmitBaseline.EncMethodDebugInfos: Map`,
+keyed by **MethodDef token** — the CDI parent of the EnC rows is a MethodDef handle, so token
+keying is unambiguous (the name keying on the write side exists only because the PDB writer
+lacks tokens). Details:
+
+- **Reader**: `EncMethodDebugInformation.readEncMethodDebugInfoFromPortablePdb` (SRM
+ `MetadataReaderProvider`, same SRM boundary as `HotReloadPdb.emitDelta`) collects all three
+ EnC blob kinds per method and decodes them with the blob decoder. Fail safe/fail closed: a
+ non-PDB or empty image yields the empty map (flag-off baselines and baselines emitted without
+ EnC CDI rows start sessions fine with no per-method data), and a method whose blobs do not
+ decode is omitted entirely.
+- **Sources**: the fsc emit hook decodes the exact emitted PDB bytes
+ (`PortablePdbSnapshot.Bytes`). The session `AddProject` path
+ rewrites the baseline in memory *without* the CDI side channel, so it additionally reads the
+ on-disk PDB as a sibling input when the snapshot carried no EnC rows (`service.fs`,
+ `createBaseline`).
+- **Generation chaining**: when a delta is emitted through the session
+ (`EmitDeltaForCompilation`), the per-method occurrence data is recomputed from the fresh
+ typed tree (`HotReloadBaseline.computeRefreshedEncMethodDebugInfos`, name→token resolution
+ fail closed on non-unique names) and `chainEncMethodDebugInfos` replaces the updated/added
+ methods' entries in the next-generation baseline — entries the fresh compile did not produce
+ are dropped rather than left stale (their lambdas must be treated as unmappable). Unchanged
+ methods keep their baseline entries, mirroring the `AddedOrChangedMethods` plumbing.
+- **Deferred**: the delta PDB does **not** yet re-emit EnC CDI rows (delta-PDB writing is a
+ separate path). Within a session the in-memory chain is the generation-accurate source the
+ name allocator consumes. Baseline (generation-0) state fully survives process restarts: the
+ on-disk PDB carries the occurrence keys and the occurrence-derived naming (below) makes the
+ names derivable from them. Rehydrating MID-SESSION (generation-N) state in a new process
+ still needs delta-PDB CDI re-emission. Methods *added* by a delta also carry no entry yet
+ (no baseline token at refresh time).
+
+### Occurrence-keyed closure name allocation as implemented
+
+`src/Compiler/TypedTree/ClosureNameAllocator.fs` is the F# `EncVariableSlotAllocator.TryGetPreviousLambda/
+TryGetPreviousClosure` analogue, expressed as a pure data transformation over the lambda occurrence model
+(no IO, no IlxGen state — fully unit-tested by `ClosureNameAllocatorTests`):
+
+- **API**: `allocateMemberClosureNames baselineOccurrences baselineNamesByOccurrenceChain
+ freshOccurrences freshNameBase generation` → per-occurrence `Assignments`
+ (`Reused of name` | `Fresh of name`, in occurrence order) plus
+ `RefreshedNamesByOccurrenceChain`, the chain-forward table the next generation consumes.
+ Tables are keyed by the **unpacked** root-first ordinal chain (`int list`), so the allocator
+ carries none of the CDI packing limits; CDI keys translate via
+ `EncMethodDebugInformation.decodeOccurrenceKey`.
+- **Alignment**: exactly the diff's two-pass LCS — the index-pair core was extracted from
+ `alignLambdaOccurrences` into `TypedTreeDiff.alignLambdaOccurrenceIndexPairs` and is shared by
+ both consumers, so diff classification and name allocation can never disagree about pairing.
+- **Decision rules** (all fail closed): matched pair with equal capture sets and a recorded
+ baseline name → `Reused` (verbatim baseline closure class name). Matched but
+ capture-incompatible (a pass-2 shape-only pair) → `Fresh` (Roslyn parity: the previous closure
+ member is stubbed, a new one is synthesized). Matched but no recorded name (unmappable method,
+ dropped chain entry) → `Fresh` — a name is never guessed. Unmatched (added) → `Fresh`. Removed
+ occurrences contribute nothing to the refreshed table, so their names are unused forever and
+ can never be reused (later allocations are always generation-suffixed, and the generation
+ counter never repeats within a session).
+- **Generation-suffixed name format** (the Roslyn `DebugId(ordinal, generation)` analogue):
+ `{baseName}@hotreload#g{generation}_o{occurrenceChain}`, e.g. `f@hotreload#g2_o3` for a
+ top-level occurrence and `f@hotreload#g2_o0_3` for a nested one (the rendering covers the
+ full root-first chain, underscore-separated, so nested added occurrences can never collide;
+ chains are bounded by the CDI key encoding — depth ≤ 2, ordinals ≤ 0xFFFF, deeper chains
+ fail closed before any name is derived — so the suffix is bounded and names never truncate).
+ Generation 0 is reserved for the baseline derivation (see "Occurrence-derived baseline
+ naming" below). The format extends the replay naming (`f@hotreload`, `f@hotreload-1`, ...)
+ — same `@hotreload` marker, but the `#g…_o…` suffix is disjoint from the `-{int}`
+ replay-ordinal space (it never parses in `tryGetHotReloadOrdinal`; snapshot canonicalization
+ drops these names from replay buckets entirely, see the replay-bucket canonicalization note
+ below). Occurrence chains are unique within a member and generations strictly increase
+ within a session, so a (baseName, generation, chain) triple is allocated at most once.
+- **Tests**: `ClosureNameAllocatorTests` covers the synthetic match/add/remove/nested/
+ capture-incompatible/fail-closed cases and three-generation chaining, and additionally drives
+ the allocator over REAL occurrence extraction (checker compiles via the shared
+ `DiffTestHarness`): an added filter lambda gets the generation-2 name while the surviving map
+ lambda keeps the baseline name, and generation 3 reuses both from the chained table. The
+ component `ClosureIdentityTests` pins the metadata-level invariant the delta path relies on
+ (and the added-lambda path 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.
+
+**Lowering wiring** (stamp bridge + baseline capture + the delta-compile hook step). As
+implemented:
+
+- `LambdaOccurrence.RootExprStamp` records the unique stamp of the occurrence's root lambda
+ (the OUTERMOST `Expr.Lambda` of a curried group, looking through `Expr.DebugPoint`/`Expr.Link`)
+ — extraction bookkeeping only, never part of the structural digest or alignment, and only
+ meaningful within the compilation that produced the expression.
+- The IlxGen closure call site (`GetIlxClosureFreeVars`) records stamp→emitted-closure-name into
+ `ClosureNameAllocationState` (a `ConditionalWeakTable` side channel keyed by
+ `CompilerGlobalState`, mirroring `CompilerGeneratedNameMapState`). Recording is armed only by
+ the emit hook's `PrepareForCodeGeneration` for capture compiles; everywhere else the call site
+ performs a single failed weak-table lookup and behaves byte-identically (EmittedIL gate).
+- The fsc emit path (`main6`, next to the CDI row computation over the same `optimizedImpls`)
+ joins the recording with the occurrence extraction via
+ `ClosureNameAllocator.computeBaselineClosureNameRows`: per-member occurrence-chain→name tables,
+ keyed by compiled name with the same fail-closed rules as the CDI emission, plus a
+ member-level completeness rule — if ANY occurrence of a member has no recorded name (closure
+ formation diverged from extraction), the member gets no table and stays on sequence replay
+ rather than risking fresh names for surviving closures.
+- The rows ride the hook contract (`ICompilerEmitHook.TryEmitWithArtifacts` /
+ `CompilerEmitArtifacts.ClosureNameRows`) into the capture, where
+ `HotReloadBaseline.resolveClosureNameRowsByToken` re-keys them by MethodDef token (unique-name
+ resolution shared with `computeRefreshedEncMethodDebugInfos`) and stores them as
+ `FSharpEmitBaseline.EncClosureNames`, the companion of `EncMethodDebugInfos`. Baselines
+ created without an in-process flag-on emit (e.g. the checker's read-from-disk path) carry the
+ empty map: the occurrence-keyed naming stays inert there and delta compiles keep pure
+ sequence-replay behavior (fail closed; names are not persisted in the PDB because the CDI
+ blob format has no name slots).
+- **Delta compiles (the hook codegen step)**: `ICompilerEmitHook.PrepareForCodeGeneration`
+ receives `tcGlobals` and the `optimizedImpls` about to be lowered (fsc `main4`, immediately
+ before `GenerateIlxCode`). When a session with non-empty `EncClosureNames` is active, the hook
+ calls `HotReloadBaseline.computeOccurrenceKeyedClosureNames`: extract occurrences from the
+ session's previous-generation impl files and from the fresh tree (unique-compiled-name keying,
+ fail closed on ambiguity), resolve each member's baseline chain→name table via its MethodDef
+ token, run `ClosureNameAllocator.allocateMemberClosureNames` per member with
+ `generation = session.CurrentGeneration`, and install the resulting stamp→assigned-name map
+ via `ClosureNameAllocationState.setAssignedClosureNames`.
+- **Call-site semantics (consume-then-override)**: the closure call site still consumes its
+ replay-map slot unconditionally (`GetUniqueCompilerGeneratedName` memoizes per stamp, exactly
+ one slot per closure), so non-closure synthesized names sharing a basic name keep their
+ baseline replay positions; the occurrence-keyed table then overrides the closure's own name
+ when its stamp was allocated. Stamps absent from the table (fail-closed members, IlxGen-
+ synthesized closures such as object expressions/sequence closures) keep the replayed name —
+ for unchanged lambda sets the allocator's `Reused` names equal the replayed names, so the
+ body-edit behavior is unchanged by construction.
+- **Chaining**: `DeltaEmissionRequest.RefreshedClosureNameRows` carries the allocator's
+ refreshed per-method tables (recomputed in `EmitDeltaForCompilation` from the same session
+ state + fresh tree — deterministic, so it agrees with what the emit-time install produced)
+ and `HotReloadBaseline.chainClosureNameRows` replaces/drops the updated methods' tables in
+ the next-generation baseline, exactly mirroring `chainEncMethodDebugInfos`. Flag-on
+ recapture compiles (the component-test flow) instead re-record and re-join, so the replaced
+ baseline's tables carry the generation's final names either way.
+- **Known residual (accepted)**: when a lambda set changes, replay slots consumed by overridden
+ closures shift the replay sequence for any OTHER synthesized-name consumer sharing the same
+ basic name; such members are exactly the ones still classified rude for emission today, and
+ the collision window (closure base names shared with non-closure synthesized names) predates
+ this wiring.
+- **Naming vs emission boundary**: the allocator fixes the NAME of every occurrence — including
+ added ones, which lower to `{base}@hotreload#g{N}_o{i}` classes in the delta compile's
+ in-memory rewrite (pinned by the `ClosureIdentityTests` added-lambda test). The added-lambda
+ emission path EMITS those added members: classification allows Added-only lambda sets when
+ the runtime advertises `NewTypeDefinition` + `AddMethodToExistingType` (and Removed-only
+ sets unconditionally — the baseline closure class just goes unused), and the delta emitter
+ detects the generation-suffix marker on fresh-compile types with no baseline TypeDef token,
+ emitting a NEW TypeDef row (+ AddField/AddMethod pairs, NestedClass row) per the Roslyn
+ reference template recorded in docs/hot-reload-member-additions.md. The added type token
+ chains into the next-generation baseline `TypeTokens`, so later generations body-edit the
+ added closure's methods in place (validated by the multi-generation runtime test
+ `ApplyUpdate succeeds for added lambda creating a new closure class`). Two wiring
+ consequences: baseline capture carries `EncClosureNames` over from the
+ in-process capture session it replaces (same MVID) because disk artifacts cannot encode
+ the tables, and MemberRef/TypeSpec token passthrough became content-validated (an added
+ lambda shifts the fresh compile's reference-row order; see the member-additions doc).
+ Genuinely new generic instantiations are no longer rude: the delta writer appends
+ TypeSpec rows (Default op, C# template parity), so an added lambda whose closure class
+ extends a brand-new `FSharpFunc` emits and applies (see the member-additions doc).
+ Still rude: capture-set changes of matched occurrences (capture-field mapping is a later
+ slice) and generic closure classes (GenericParam emission). The MVID-matched
+ `EncClosureNames` carry-over during baseline capture is superseded by the
+ occurrence-derived reconstruction (below) and demoted to a consistency check.
+
+### Occurrence-derived baseline naming and cross-process reconstruction
+
+The lowering wiring above initially left one in-memory dependency: the generation-1 chain→name
+tables were captured during baseline IlxGen (stamp→name recording) and could not be re-derived
+from disk artifacts, so a session started from the on-disk baseline — the dotnet-watch topology,
+where fsc builds in a separate process from the FCS session — had empty `EncClosureNames`
+and the allocator failed closed on every lambda set change. Occurrence-derived baseline naming
+closes this the Roslyn way: **names are functions of identity and are never persisted.**
+
+- **The derivation function**: under `--test:HotReloadDeltas` the BASELINE compile names
+ every mapped closure class `{memberCompiledName}@hotreload#g0_o{chain}` — the allocator's
+ fresh-name format at generation 0, with the occurrence's root-first ordinal chain rendered
+ underscore-separated (`ClosureNameAllocator.formatGenerationSuffixedClosureName`,
+ `formatOccurrenceChainKey`). The base name is the member's compiled name (after IlxGen's
+ type-name character cleanup) — the same base the delta allocator already used for fresh
+ names — NOT the IlxGen let-bound basename, so the name is recomputable from the MethodDef
+ row alone. Implementation: `HotReloadEmitHook.PrepareForCodeGeneration` derives the
+ stamp→name table from the same `optimizedImpls` extraction the CDI emission consumes
+ (`HotReloadBaseline.computeBaselineOccurrenceKeyedClosureNames`) and arms the existing
+ assigned-name side channel before lowering; the closure call site's consume-then-override
+ semantics are untouched. Gating mirrors the CDI emission EXACTLY (unique compiled names,
+ every chain encodable), so a name is derived if and only if its occurrence key is
+ persisted; members failing the gates keep replay naming and stay fail-closed for
+ set-changes, byte-for-byte like before.
+- **Reconstruction** (`HotReloadBaseline.deriveEncClosureNamesFromEncDebugInfos`): baseline
+ creation — both the in-process capture and the checker's read-from-disk path — re-derives
+ `EncClosureNames` from the decoded `EncMethodDebugInfos` occurrence keys plus the member
+ name from the MethodDef token. Fail closed twice: (1) a baseline containing ANY
+ generation-suffixed TypeDef of generation ≥ 1 is a mid-session recapture artifact (its
+ added-closure names carry their first-allocation generation and are not derivable from
+ gen-0 identity) — no table is reconstructed at all; (2) per member, every derived name
+ must exist as a baseline TypeDef simple name — this drops older baselines whose closures
+ carry replay names and members with occurrences that never lowered to closure classes, so
+ a table can never claim a name the baseline does not contain.
+- **Recorder and carry-over demoted**: the stamp→name recording remains the table SOURCE
+ only for recapture compiles emitted under an active session (their names legitimately
+ carry later generations); for plain baseline captures it is a validation — derived ==
+ recorded wherever both produced a table (trace + debug assertion on mismatch). The
+ MVID-matched carry-over during baseline capture became a consistency check (a gen-0
+ in-process session disagreeing with a non-empty reconstruction asserts; an EMPTY
+ reconstruction against a recapture session is the designed fail-closed outcome).
+- **Replay-bucket canonicalization**: generation-suffixed names are allocator-managed and
+ never replayed, so `FSharpSynthesizedTypeMaps.LoadSnapshot` drops them from replay
+ buckets; the surviving replay-ordinal names are placed at their EXACT ordinal slots
+ (holes filled with the deterministic slot names), so non-closure synthesized names
+ sharing a basic name keep their baseline replay positions across disk restores — the
+ slots consumed (and overridden) by derived-named closures stay reserved.
+- **Determinism pin**: `RuntimeIntegrationTests` proves an in-process capture session and a
+ disk-started session for the same output assembly carry IDENTICAL tables (token, chain
+ and name), including depth-2 chains; the cross-process addition/removal tests build the
+ baseline through the command-line fsc path, reset all in-process session state, start
+ from disk only, and drive add → ApplyUpdate → invoke → follow-up body edit chains.
+- **Accepted residuals**: mid-session (generation-N) state still does not survive a process
+ restart — that needs delta-PDB CDI re-emission (unchanged deferral); recapture-produced
+ DLLs are not reconstructable by design (fail closed to replay); members whose chains
+ exceed the CDI encoding (depth > 2) fail closed identically in-process and from disk
+ (an earlier in-process recorder kept replay-name tables for them — an in-memory-only
+ capability inconsistent with the persistence model, removed).
+
+The underlying design rationale:
+
+1. *Where closure names are born*: one call site — `IlxGen.GetIlxClosureFreeVars` calls
+ `StableNameGenerator.GetUniqueCompilerGeneratedName(basename, expr.Range, uniq)`, which lands in
+ `ICompilerGeneratedNameMap.GetOrAddName basicName`. The seam is keyed by basic-name SEQUENCE
+ only: it carries no occurrence identity and no name-kind discrimination (closure type names,
+ static-field helpers, record/union helpers all flow through the same `GetOrAddName`).
+2. *Bridging identity*: IlxGen cannot replay the diff's occurrence numbering (curried-group merging,
+ member-top stripping, delay-lambda elimination happen in extraction, not lowering, and closure
+ formation can be deferred for let-rec/local type functions). The identity available at BOTH
+ ends within one compile is the lambda expression's unique stamp (`Expr.Lambda(uniq, ...)`),
+ already in hand at the call site. So: record the root expression stamp on `LambdaOccurrence`
+ (extraction-time capture, never part of the structural digest), so an allocation can be turned
+ into a stamp→name table by extracting occurrences from the very tree IlxGen lowers.
+3. *Baseline derivation gap*: the baseline CDI stores occurrence keys, not names, and the name-map
+ snapshot stores names by basic-name sequence — joining them by allocation order would
+ re-introduce exactly the sequence keying the occurrence-keyed allocation removes. The
+ trustworthy generation-1 chain→name table must be captured during baseline IlxGen: record
+ stamp→emitted closure type name at the call site and join with the stamp→chain table from the
+ same tree's extraction in the fsc emit path (where the emit path already computes CDI rows
+ from `optimizedImpls`); store per MethodDef token on the session baseline and chain it like
+ `EncMethodDebugInfos`. (The CDI blob format has no name slots — Roslyn recomputes C# names
+ from `DebugId` alone, which F# cannot do for baseline occurrences whose names embed line
+ numbers or replay ordinals; the occurrence-derived `#g0` naming closes exactly this gap.)
+4. *Process topology*: in the watch flow the delta IL is produced by the fsc emit pipeline (the
+ emit hook's `PrepareForCodeGeneration` installs the replay map on fsc's `CompilerGlobalState`)
+ while the session chains state in the checker. The hook contract therefore has a codegen-time
+ step that (a) pulls the session's per-method chain tables, (b) extracts fresh occurrences from
+ the impl files being lowered (same-tree stamps), (c) runs the allocator per member, and (d)
+ installs the stamp→name table next to the name map (a `ConditionalWeakTable` seam like
+ `CompilerGeneratedNameMapState`) for the closure call site to consult before falling back to
+ sequence replay. Flag-off and non-session compiles see no table and stay byte-identical.
+5. *Seam discrimination*: no heuristics on name text — the closure call site consults the
+ occurrence table directly (name-kind discrimination by construction: only that call site
+ looks); every other synthesized name keeps the existing `GetOrAddName` replay behavior.
+
+### Force-rebuilt on-disk outputs (the dotnet-watch topology)
+
+The dotnet-watch host force-rebuilds the project output (`dotnet build`, a separate fsc
+process) between edits, and the FCS session consumes that on-disk dll as each
+generation's FRESH module (`FSharpHotReloadSession.EmitDelta` reads the output path; the typed trees
+come from the session's own check). Two semantics were pinned down while fixing the
+generation-2 crash this topology exposed (`InvalidProgramException` in a
+generation-1-added closure's `.cctor`):
+
+- **Name authority is the compile that produced the fresh module.** An in-process delta
+ compile (the fsc emit-hook flow) names added occurrences with the allocator's
+ `#g{N}_o{chain}` because `PrepareForCodeGeneration` installed the assigned-name table;
+ a SESSION-LESS rebuild has no session state, so it names every mapped closure with the
+ gen-0 baseline derivation `#g0_o{chain}` — including occurrences the session considers
+ "added in generation N". Both are correct: the session is name-agnostic. The delta
+ emitter detects added closures by the generation-suffix marker plus the absence of a
+ baseline TypeDef token, emits the new TypeDef under the fresh module's ACTUAL name and
+ chains that name into `TypeTokens`/`MethodTokens`/`FieldTokens`; the next session-less
+ rebuild re-derives the identical `#g0` name (the derivation is a pure function of
+ occurrence identity), so later generations resolve the added closure in place. The
+ allocator's `#g{N}` fresh names materialize only in modules the allocator actually
+ lowered. (With a lambda-free baseline — the crash scenario — `EncClosureNames` is empty
+ and chains nothing, so the session tables stay vacuously consistent.)
+- **The recapture guard does not (and must not) fire for force-rebuilt baselines.** A
+ rebuilt on-disk dll whose sources now contain the edits carries only `#g0` names — it
+ is indistinguishable from an original baseline because it IS one: a valid generation-0
+ artifact for any session (re)started from it. The `#g{N>=1}` fail-closed guard targets
+ mid-session RECAPTURE artifacts only. The LIVE session is unaffected by on-disk
+ rebuilds because its baseline (assembly bytes, metadata snapshot, handle caches,
+ token maps) is captured once by `FSharpHotReloadSession.AddProject` and never re-read; the rebuilt
+ dll enters delta emission only as the fresh compile.
+
+The generation-2 crash itself was a fresh-vs-baseline ROW COORDINATE bug in the delta
+emitter, not a naming bug: re-emitting rows of methods ADDED by an earlier generation
+(the closure's `.cctor`/`.ctor`/`Invoke` become method updates of generation 2 via the
+chained baseline tokens, which sit PAST the original tables) read their
+attributes/signature/body/parameters from the fresh reader AT the chained baseline row —
+but the fresh compile lays the closure out at its natural source position, displacing
+every later row (here: the startup-init methods), so the wrong method's body was emitted
+and the JIT rejected the `.cctor`. `tryBuildMethodUpdateInput` now reads the fresh module
+through the fresh token recorded by `collectTypeMappings` (the baseline token remains the
+emission row), pinned by the component test
+`Added closure survives a second edit when the on-disk output is force-rebuilt between
+generations`.
+
+### State machines (builds on the same map)
+
+#### Ground truth (flag-on compiles, inspected via ikdasm + live session probes)
+
+- **`async`** lowers to closure chains: ~10 nested `FSharpFunc` subclasses for a two-bind
+ CE (`compute@hotreload`, `compute@hotreload-1` … `-9`), including classes produced by
+ inlined `AsyncPrimitives` internals — MORE closure classes than lambda occurrences,
+ with legacy range-erased names (NOT occurrence-derived `#g0_oN` names). Consequences:
+ body edits inside `async { }` work end-to-end (deterministic names align 1:1), but
+ STRUCTURAL changes (added/removed `let!`, CE-level `try/with`) shift the `-N`
+ numbering and the synthesized-type mapping fails closed with the
+ "Ambiguous synthesized type mapping" `UnsupportedEdit` (observed live). The
+ added-closure path cannot cover these classes because they are not 1:1 with
+ occurrence-modelled lambdas (the stamp seam never sees the inlined internals).
+- **`task`** lowers to ONE nested STRUCT state machine (`compute@hotreload`, extends
+ `ValueType`): fields `Data`, `ResumptionPoint`, hoisted locals (`input`, `x`), and
+ positionally named awaiters (`awaiter`, `awaiter0`, …); methods `MoveNext`,
+ `SetStateMachine`, `get_ResumptionPoint`, `get_Data`, `set_Data`. Resume-point state
+ numbers are assigned POSITIONALLY at lowering (`LowerStateMachines.genPC`, 1..N in
+ conversion order). Observed live, before the emission backstops below existed: a body
+ edit with stable resume points emitted, applied, and ran correctly; an ADDED `let!`
+ emitted, applied, and then CRASHED at invoke (the emitter's compiler-generated-field
+ skip silently dropped the new awaiter struct field). Reordered awaits failed closed
+ via capture-set rude edits.
+- **`seq`** lowers to a CLASS state machine via `GenSequenceExpression` (not
+ ResumableCode); body edits apply end-to-end (`tier1-seq` test).
+
+#### Classification
+
+The old blanket `StateMachineShapeDigest` (a distinct-set of TryWith/TryFinally/While/
+ForLoop ops + `MoveNext` valref names) was BOTH over-broad (a plain method gaining a
+`while` loop, or an expression-level `try/with` inside `async { }`, was rude FSHRDL013)
+and under-protective (it never caught the `task` added-await crash above). It is
+replaced by typed resumable-code evidence:
+
+- A member contains a genuine state machine iff its body calls members returning
+ `ResumableCode<_,_>` (after abbreviation stripping — `TaskCode` etc.; matched by
+ compiled identity because the diff compares trees across compilations), constructs a
+ `ResumableCode` delegate directly, or makes SRTP trait calls returning resumable code.
+- The digest is the ORDERED sequence of those calls with their type instantiations
+ (`resumable=[Delay(2),Bind(2),Return(2)]`): state numbers
+ are positional and the struct's awaiter/hoisted layout follows the sequence, so order
+ is part of the shape.
+- Rules for resumable members (C# parity: `ChangingStateMachineShape`, FSHRDL013):
+ - step sequence unchanged + lambda occurrence edits all `BodyEdited` → MethodBody
+ update (MoveNext body flows through the existing method-update machinery; struct
+ TypeDef, fields, and state numbers survive);
+ - step sequence changed (added/removed/reordered `let!`/`do!`/`return!`/CE control
+ flow, including non-suspending steps like an inserted `Zero`/`Combine` — documented
+ over-approximation) → rude FSHRDL013 with the digest diff in the message;
+ - sequence unchanged but structural lambda churn (added/removed continuations or
+ capture-set changes) → rude FSHRDL013 (hoisted layout would change).
+ - Append-only new awaits are NOT allowed, unlike C# Debug-mode async methods: C#'s
+ Debug state machines are classes (new awaiter/hoisted fields ride
+ `AddInstanceFieldToExistingType`); F# task state machines are structs, whose layout
+ is immutable under EnC, so ANY resume-point addition is rude.
+- Plain control flow (while/for/try) is no longer state-machine evidence anywhere:
+ those constructs lower to ordinary IL in the containing (closure) body.
+- `async` stays under the lambda occurrence model (its builder returns `FSharpAsync`,
+ not `ResumableCode`): body edits (including new expression-level control flow) are
+ MethodBody updates; structural CE changes keep failing closed at emission via the
+ synthesized-type-mapping guard (precise `UnsupportedEdit`, see ground truth above).
+- `seq { }` also stays under the lambda occurrence model (its desugaring is a closure
+ chain at `--optimize-`, not ResumableCode): body edits apply as MethodBody updates and
+ an ADDED yield rides the added-closure path (requires `NewTypeDefinition` +
+ `AddMethodToExistingType`; fresh enumerations observe the new yields — pinned by
+ `ApplyUpdate succeeds for seq body edit with added yield`). Documented caveat,
+ deliberately weaker than C# (which reports `ChangingStateMachineShape` for iterator
+ edits): an enumerator already SUSPENDED mid-sequence at apply time resumes on the new
+ code with its old state; F# accepts the edit because the lowering regenerates the
+ whole closure chain consistently for fresh enumerations.
+
+#### Emission backstops (`IlxDeltaEmitter.collectTypeMappings`)
+
+Classification runs on typed trees; the delta emitter independently guards the lowered
+artifacts (defense in depth, and the only guard in skew scenarios such as a disk-started
+session whose on-disk binary diverges from the baseline source):
+
+- **Struct layout gate**: a fresh instance field on an EXISTING struct raises a precise
+ `UnsupportedEdit` BEFORE the compiler-generated-name skip (which previously swallowed
+ it). For compiler-generated structs the message names the state machine and the
+ changed resume-point/hoisted layout — this was the exact hole behind the task
+ added-await runtime crash described above.
+- **Injective synthesized-type mapping**: the new→baseline type-name mapping must be
+ 1:1; two fresh closures alias-matching one baseline class (legacy `-N` numbering
+ shift after a structural async CE change) fail closed instead of patching the wrong
+ rows.
+- **Legacy closure fallthrough**: a non-generation-suffixed `@hotreload` type with no
+ baseline counterpart raises a precise "closure chain cannot be aligned" message
+ instead of falling through to garbage baseline token lookups.
+
+#### EnC State Machine State Map persistence
+
+The Roslyn-format state map blob (`serializeStateMachineStates`) is emitted at baseline
+and read back:
+
+- `LowerStateMachines` surfaces the conversion's resume points on
+ `LoweredStateMachine.resumptionPoints` — (state number, source range) in
+ state-number order, the AUTHORITATIVE numbering (`genPC`), not a typed-tree guess.
+- `IlxGen.GenStructStateMachine` records the state numbers into the closure-name
+ recording seam (`ClosureNameAllocationState.recordStateMachineResumePoints`), keyed by
+ the emitted struct's full type name; recording shares the closure-name recording
+ lifecycle (capture compiles only, flag-off byte-identical).
+- The fsc emit path joins recordings to members by the struct simple name's basic name
+ (`{member}@hotreload…` → `{member}` = the member's compiled name, this conduit's key),
+ failing closed on collisions (same-named members, nested CEs lowering several
+ machines in one member); the PDB writer additionally drops any name that does not
+ identify exactly one IL method row.
+- Blob semantics: `StateNumber` = the positional pc (1..N); the syntax-offset slot
+ carries the resume point's ORDINAL (the same philosophy as the lambda-map occurrence
+ keys — deterministic ints, not source offsets). Decodable by Roslyn-format tooling.
+- The baseline read side (`readEncMethodDebugInfoFromPortablePdb` →
+ `FSharpEmitBaseline.EncMethodDebugInfos`) decodes the rows for disk-started sessions.
+
+Deviations from Roslyn, stated precisely: (1) rows attach to the KICKOFF member's
+MethodDef, not MoveNext — F# state-machine MoveNexts share one name and the writer
+conduit is name-keyed, while every consumer addresses members; (2) disk-started-session
+CLASSIFICATION remains source-derived (the session re-typechecks the baseline source,
+so the resumable-call digests exist without the CDI), with binary skew guarded by the
+struct-layout emission gates; CDI-driven cross-checking of fresh-vs-persisted state
+counts at emission is deferred until a scenario demands it.
+
+### Resulting rude-edit surface (parity with C#)
+
+Allowed: adding/removing/reordering lambdas; editing lambda bodies; editing bodies inside
+`async`/`task`/`seq` CEs when the CE structure is unchanged; plain control-flow additions
+anywhere. Still rude: captured-variable rename/type/scope change, lambda signature
+changes, ANY resume-point sequence change in resumable (task) members (struct layout),
+structural `async` CE changes (closure-chain alignment, fails closed at emission),
+struct-closure capture additions.
+
+## Open questions
+
+1. ~~Occurrence alignment tolerance~~ — resolved: full structural digest first, shape-only digest
+ second; positional fallback only between indistinguishable occurrences.
+2. F# `let` local functions: lowered as methods or closures depending on capture/inline decisions —
+ the occurrence model keys off the typed tree the diff already walks; the lowering decision is
+ replayed via the name-map state where occurrence identity threads into IlxGen.
+3. Quotation-bearing code (`QueryExpressionShapeChange`): kept rude (members containing
+ `Expr.Quote` are excluded from occurrence modelling); revisit as added-lambda emission
+ coverage broadens.
+4. Whole-body hash sensitivity: the FNV/XOR combination in `exprDigest` treats swaps of adjacent
+ independent `let` bindings as identical (XOR commutes), so a pure reorder may produce no
+ MethodBody edit at all. Pre-existing behavior, documented by the occurrence-reorder test;
+ tighten when the hash is next revised.
diff --git a/docs/hot-reload-member-additions.md b/docs/hot-reload-member-additions.md
new file mode 100644
index 00000000000..dbbeae88c40
--- /dev/null
+++ b/docs/hot-reload-member-additions.md
@@ -0,0 +1,956 @@
+# Hot reload: emitting added members
+
+This note records the delta format decisions behind emitting ADDED members
+(static fields, instance fields, methods, parameters, properties, events) in
+F# hot reload deltas, the C# reference deltas they were validated against, and
+the runtime (CoreCLR) behavior that forced the EncLog shape used today.
+
+## C# reference EncLog (hotreload-delta-gen)
+
+Scenario: `public static int AddedStatic = 42;` added to a class whose static
+constructor already exists (`csharp_delta_test`, SimpleLib_v1.cs). The
+generation-1 delta produced by Roslyn (read with mdv) contains:
+
+```
+EnC Log:
+1: AssemblyRef 0x23000002 Default (new AssemblyRef row)
+2: TypeRef 0x0100000f Default (new TypeRef row)
+3: TypeDef 0x02000002 AddField <- PARENT of the added field
+4: Field 0x04000002 Default <- the added Field row
+5: MethodDef 0x06000003 Default (updated .cctor body)
+
+EnC Map: TypeRef 0x0100000f, Field 0x04000002, MethodDef 0x06000003,
+ AssemblyRef 0x23000002 (token-sorted; NO entry for the
+ AddField parent TypeDef)
+```
+
+The PDB delta's EncMap contains a single `MethodDebugInformation 0x31000003`
+row for the updated constructor plus its sequence points.
+
+Field signature blob: `06-08` (FieldSig per ECMA-335 II.23.2.4: FIELD, int32).
+
+## EncLog shape for added members (CLR requirement, not just parity)
+
+CoreCLR's EnC applier (`CMiniMdRW::ApplyDelta`, metamodelenc.cpp) interprets
+an `Add*` operation as: *the token in THIS record is the PARENT; the NEXT
+record defines the new member row*:
+
+| Operation | Parent token in the Add* record | Following record |
+|---------------|---------------------------------|-------------------|
+| AddMethod (1) | TypeDef | Method, Default |
+| AddField (2) | TypeDef | Field, Default |
+| AddParameter (3) | MethodDef | Param, Default |
+| AddProperty (4) | PropertyMap | Property, Default |
+| AddEvent (5) | EventMap | Event, Default |
+
+Newly created PropertyMap/EventMap rows are logged as plain `Default` entries
+BEFORE the `AddProperty`/`AddEvent` entries that reference them; the parent
+map row id is used even when the map already exists in the baseline.
+MethodSemantics rows are plain `Default` entries. Only the added member row
+(never the parent entry) appears in EncMap.
+
+Historically the F# writer put the `Add*` operation on the member row itself
+(e.g. `(Method, rowId, AddMethod)`). That shape can NOT be applied by the
+runtime: `ApplyDelta` treats the member rid as a parent rid, corrupting member
+lists, and never applies the member row contents. It went unnoticed because no
+test ever runtime-applied an added member. The writer now uses the Roslyn/CLR
+parent+pair shape for ALL member kinds; there is no remaining method-path
+discrepancy.
+
+The numeric operation codes were also corrected to the CLR/SRM values
+(`AddParameter=3, AddProperty=4, AddEvent=5`; they were previously 4/5/6).
+
+## Heap offsets for added members
+
+- The baseline `#Strings` size must be SRM's *trimmed* size (alignment padding
+ reduced to a single terminating zero, `StringHeap.TrimEnd`), which is what
+ Roslyn's `EmitBaseline` uses and what EnC heap aggregation assumes.
+ `ILBaselineReader.metadataSnapshotFromBytes` now trims; using the raw
+ (padded) stream size shifted every delta-heap string reference by the
+ padding bytes.
+- Added member rows must write their name/signature into the DELTA heaps
+ (offset `None` in the row infos). Offsets captured from the fresh in-memory
+ compile's heaps are meaningless against the baseline+delta heap layout.
+- Signature blobs entering the delta blob heap (method, field, property) flow
+ through `remapSignatureBlobWith` so embedded TypeDefOrRef coded indexes are
+ remapped from the fresh compile to baseline rows.
+
+## Param rows
+
+Return-parameter rows are never synthesized (Roslyn parity). The fresh compile
+emits Param rows only for real parameters, ordered by sequence number. A
+synthesized seq-0 row appended after real seq-1+ rows is out of sequence,
+which forces the CLR's `FixParamSequence` onto the indirect ParamPtr-table
+path and makes `MetadataUpdater.ApplyUpdate` reject the delta (observed with
+added module-value accessors).
+
+The `ParamList` column of ADDED method rows is kept monotone: a parameterless
+added method points at the NEXT param row rather than 0 (the CLR suppresses
+this column for deltas, but mdv/ECMA readers compute ranges from it).
+
+## Module-level value additions (F# lowering)
+
+`let mutable newCounter = 41` added to `module Sample.Library` lowers to:
+
+- static fields `newCounter@` and `init@` on the startup-code class
+ `.$Sample.Library` (NOT the module type),
+- static accessors `get_newCounter`/`set_newCounter` plus a `newCounter`
+ property on the module type,
+- a `.cctor` on the startup-code class that runs the initializer.
+
+When the baseline already contains the startup `.cctor` (the module already
+had values), the diff cannot pair it (it is not a typed-tree binding);
+`DeltaBuilder.mapSymbolChangesToDelta` resolves it by name
+(`.$::.cctor`) and adds it as an updated method.
+When the baseline has no startup `.cctor` yet, the fresh compile introduces it
+and the emitter discovers it as an added method.
+
+### Initialization semantics (validated at runtime)
+
+Matches C# EnC semantics, verified with both the Roslyn reference delta and
+the F# runtime test:
+
+- If the type holding the backing field has NOT been type-initialized yet,
+ the (added/updated) static constructor runs lazily on first access and the
+ value reads its initializer (the F# module-value test observes `41`; the
+ C# reference observes `42`). This is the common case for F# module values
+ whose startup class had no prior static state.
+- If the type was already initialized, the constructor does not re-run and
+ the added field reads `default(T)`.
+
+## Instance field additions
+
+### C# reference EncLog (Roslyn EmitDifference, csharp_enc_reference `members` scenario)
+
+Scenario `field_add`: `public int NewInstanceField = 42;` added to an existing
+class with an explicit constructor (edits: Insert(field) + Update(.ctor)).
+Generation-1 delta (mdv: `csharp_enc_reference/reference_mdv_field_add.txt`):
+
+```
+EnC Log:
+1: AssemblyRef 0x23000002 Default
+2: MemberRef 0x0a000006 Default (Object::.ctor for the re-emitted ctor body)
+3: TypeRef 0x01000007 Default (Object)
+4: TypeDef 0x02000002 AddField <- PARENT of the added field
+5: Field 0x04000002 Default <- the added instance Field row
+6: MethodDef 0x06000001 Default (updated .ctor body, runs the initializer)
+
+EnC Map: TypeRef add, Field add, MethodDef 1 UPDATE, MemberRef add,
+ AssemblyRef add (token-sorted; NO entry for the AddField parent)
+```
+
+Identical `(TypeDef, AddField) + (Field, Default)` pairing as the static
+case above; the only structural difference from a static field add is the paired
+constructor update instead of a `.cctor` update.
+
+### F# lowering and classification
+
+- `type C() = let mutable x = 41` folds the initializer into the primary
+ constructor: the typed-tree diff pairs the `.ctor` binding (and any member
+ body reading the field) as plain MethodBody updates, and the field itself
+ surfaces only through the ENTITY representation (`fsobjmodel_rfields`).
+ Constructor pairing therefore needs no by-name resolution (unlike the
+ module-value startup `.cctor`): the ctor IS a typed-tree binding. The diff's runtime
+ return-type identity for constructors is `void` (the IL truth), not the
+ constructed type — required for `.ctor` MethodBody edits to resolve against
+ baseline method tokens.
+- `[] val mutable` produces NO binding or body change at all:
+ the entity-level field diff emits a `SemanticEditKind.TypeDefinition` edit
+ (symbol path mirrors the IL type name) so delta emission runs, and the
+ generation-1 delta is a pure Field-row append with zero method updates. The
+ metadata writer's empty-delta short-circuit keys on row payload (method
+ updates OR added type/field/property/event rows), not method updates alone.
+- Entity classification (`compareEntities`) recognizes a PURE field addition
+ (non-field representation unchanged, baseline fields preserved verbatim,
+ only new fields) on a CLASS (`TFSharpClass`) and gates it on
+ `AddInstanceFieldToExistingType` / `AddStaticFieldToExistingType` per added
+ field; a missing capability reports `RudeEditKind.NotSupportedByRuntime`
+ naming it. STRUCT (and record/union/enum) field additions remain
+ `TypeLayoutChange` permanently — the runtime cannot re-layout value types
+ (C# identical). The emitter enforces the same restriction fail-closed
+ (`typeDef.IsStructOrEnum`).
+
+### Initialization semantics (validated at runtime)
+
+C# EnC parity, asserted by the runtime tests:
+
+- Instances constructed BEFORE the update read `default(T)` for the added
+ field (their constructor never ran the new initializer); their existing
+ state is preserved across generations.
+- Instances constructed AFTER the update run the UPDATED constructor and see
+ the initializer value.
+- Multi-generation: a second added field chains through the updated baseline
+ (gen-1 field tokens resolve; only the new Field row is appended).
+
+## Property and event additions
+
+### C# reference EncLog (Roslyn EmitDifference, csharp_enc_reference `members` scenario)
+
+Scenario `prop_add`: `public int NewProp { get; set; }` added to a class that
+already has one auto-property (single edit: Insert(property); Roslyn
+synthesizes the accessors and backing field). Generation-1 delta (mdv:
+`csharp_enc_reference/reference_mdv_prop_add.txt`):
+
+```
+EnC Log:
+ 1: AssemblyRef 0x23000002 Default
+ 2: MemberRef 0x0a000008 Default (CompilerGeneratedAttribute..ctor)
+ 3: MemberRef 0x0a000009 Default (DebuggerBrowsableAttribute..ctor)
+ 4: TypeRef 0x0100000a Default (Object)
+ 5: TypeRef 0x0100000b Default (CompilerGeneratedAttribute)
+ 6: TypeRef 0x0100000c Default (DebuggerBrowsableState)
+ 7: TypeRef 0x0100000d Default (DebuggerBrowsableAttribute)
+ 8: TypeDef 0x02000002 AddField <- backing field parent
+ 9: Field 0x04000002 Default <- 'k__BackingField'
+ a: TypeDef 0x02000002 AddMethod
+ b: MethodDef 0x06000005 Default (get_NewProp)
+ c: TypeDef 0x02000002 AddMethod
+ d: MethodDef 0x06000006 Default (set_NewProp)
+ e: PropertyMap 0x15000001 AddProperty <- parent: the EXISTING baseline map row
+ f: Property 0x17000002 Default <- the added Property row
+10: MethodDef 0x06000006 AddParameter
+11: Param 0x08000002 Default ('value')
+12-15: CustomAttribute x4 Default ([CompilerGenerated]/[DebuggerBrowsable]
+ on the backing field + accessors)
+16: MethodSemantics 0x18000003 Default (getter binding)
+17: MethodSemantics 0x18000004 Default (setter binding)
+
+Property row Get/Set columns render nil (EnC): accessors are linked through
+the MethodSemantics rows, exactly like FieldList/MethodList for added types.
+```
+
+### F# emission shape
+
+- **MethodSemantics rows are derived from the fresh compile's accessor
+ relationships** (`PropertyDefinition.GetAccessors()` /
+ `EventDefinition.GetAccessors()` on the fresh metadata, accessor method
+ tokens remapped to baseline/delta MethodDef rows) — Roslyn parity:
+ `DeltaMetadataWriter` emits semantics from the symbol model, not from the
+ edit list. Every ADDED Property/Event row therefore carries its
+ Getter/Setter/Adder/Remover/Raiser bindings even when the accessors are
+ compiler-synthesized (module values, `[]` members get them too —
+ previously the module-value property row shipped without semantics rows).
+ A Property/Event row whose accessors cannot be bound fails closed
+ (`HotReloadUnsupportedEditException`): such a row is corrupt metadata.
+- The delta builder does NOT resolve ADDED accessors against the baseline
+ (there is nothing to resolve): added accessor methods are discovered by the
+ emitter walking the fresh module, like any added method. `UpdatedAccessors`
+ remains the body-update path for EXISTING accessors.
+- Events bind BOTH adder and remover (and raiser when present); the
+ `EventType` column of ADDED Event rows is remapped from the fresh compile
+ to baseline/delta rows through the content-validated reference remapper
+ (appending a TypeRef row when no baseline row matches — Roslyn likewise
+ re-emits TypeRefs used by the delta).
+- New PropertyMap/EventMap rows are logged as plain `Default` entries before
+ the `AddProperty`/`AddEvent` entries referencing them; when the map row
+ already exists (baseline or chained from an earlier generation) the parent
+ entry reuses it and no map row is emitted. Validated across generations at
+ runtime (gen-1 adds the map row, gen-2 reuses it from the chained baseline).
+- An F# auto-property (`member val`) composes the instance-field machinery with the
+ accessor/property machinery: AddField pair (backing field, ctor-paired
+ initializer) + two AddMethod pairs + AddProperty pair + two MethodSemantics
+ rows — the recorded C# template minus the custom-attribute rows (CA
+ emission for added members is still deferred; see Known gaps).
+- A `[]` member lowers to a backing `Event<_,_>` instance field
+ (ctor-paired initializer) + add_/remove_ accessors + the Event row; the
+ typed-tree `get_` PropertyGet symbol has no IL counterpart and is
+ ignored. Validated at runtime: the added event is subscribable through
+ `Type.GetEvent` reflection and fires.
+
+Runtime validation: added properties are readable/writable through ordinary
+`Type.GetProperty` reflection on LIVE instances (semantics rows wired the
+accessors); auto-property state follows C# EnC semantics (existing instances
+read `default(T)`, new instances run the updated ctor and see initializers).
+
+### Accessor edits and Property/Event row coordinates (follow-up closed)
+
+The fresh-vs-baseline row coordinate rule established for MethodDef rows
+("read updated method rows from fresh-compile coordinates") also applies to
+the Property/Event handles registered by the accessor-edit path
+(`UpdatedAccessors` -> `tryResolveAccessor` -> `registerProperty/EventDefinition`
+in `IlxDeltaEmitter.fs`):
+
+- The accessor lookup yields the FRESH reader's Property/Event handle; the
+ association remap (fresh -> chained baseline token) only names the emission
+ row. Re-registration previously rebuilt the handle FROM the chained baseline
+ token and indexed the fresh reader with it — once an earlier generation
+ added Property/Event rows ahead of the member in the fresh layout (e.g. a
+ property added to a type declared before another type's baseline property),
+ that read landed on the DISPLACED neighbour row. The handles now stay in
+ fresh coordinates, and the snapshot offsets follow the method-row rule
+ (heap offsets only from the on-disk baseline cache; otherwise the
+ name/signature is delta-heap content).
+- The displaced read was LATENT, never reaching emitted bytes: both delta
+ writers emit Property/Event (and PropertyMap/EventMap) rows only for ADDED
+ members, so an accessor BODY edit produces a MethodDef-only update — Roslyn
+ parity: an accessor body edit does not change the Property/Event row.
+ Pinned by `Added property survives an accessor edit when a later type
+ displaces its fresh row` (RuntimeIntegrationTests): gen 1 adds a getter-only
+ property whose chained row is displaced in later fresh compiles, gen 2 edits
+ the getter body and must emit no Property-table entries while the property
+ stays fully reachable via reflection.
+- The Event branch of the same path is additionally unreachable for body
+ edits today: editing a `[]` member body fails closed at symbol
+ mapping (the typed-tree `get_` PropertyGet symbol has no IL
+ counterpart to resolve). Pinned by `Added CLIEvent chains past a baseline
+ event and a later body edit fails closed`, which also validates the
+ displaced ADDED Event row itself (chained row 2, fresh row 1, new EventMap
+ row for the declaring type) applies and fires.
+
+### mdv parity notes (field/property/event consolidation)
+
+- A three-generation chain mixing method, instance-field, and auto-property
+ additions aggregates cleanly under mdv (no `EnCMap`/table errors), and mdv
+ resolves the gen-3 Property row's accessors through the emitted
+ MethodSemantics rows.
+- Next-generation table-row chaining counts only APPENDED rows (EncMap
+ entries past the baseline row counts). The delta's physical tables also
+ carry re-emitted rows for UPDATED definitions; counting those advanced the
+ row cursors past a gap, so a later generation's added rows produced an
+ EncMap readers reject ("EnCMap table not sorted or has missing records")
+ and members the runtime could not link. Exposed by the mixed-additions
+ chain (gen 1 update+add in the same table), previously latent.
+- Divergences from the recorded C# templates, all deliberate this slice:
+ EncLog group ordering is the established F# one (see the added-closure-class
+ notes below) rather
+ than Roslyn's strict by-table interleaving; F# auto-property backing fields
+ keep their F# names (`NewProp@`) instead of C#'s `k__BackingField`.
+ (Custom-attribute rows on added members were initially deferred and are now
+ emitted — see "Custom attribute rows" below.)
+
+## New type definitions (added closure classes)
+
+### C# reference EncLog (Roslyn EmitDifference, csharp_enc_reference harness)
+
+Scenario: `Compute(int x)` (no lambdas in the baseline) gains
+`System.Func f = y => y + x;` — Roslyn synthesizes a NEW display
+class `<>c__DisplayClass0#1_0#1` (nested in `SimpleLib`, generation-suffixed
+`#1` names) in the generation-1 delta. The hotreload-utils workspace tool
+could not run on this SDK (net11-only toolchain), so the reference deltas
+were produced with a compiler-level `Compilation.EmitDifference` harness
+(`hot_reload_poc/src/csharp_enc_reference`, Roslyn 5.9.0-1.26302.3) — the
+IDE/dotnet-watch pipeline ends in the same API, so the delta shape is
+identical. Full mdv output: `csharp_enc_reference/reference_mdv_added_lambda.txt`.
+
+Baseline tables: 2 TypeDefs, 2 MethodDefs, 1 Param, 0 Fields, 5 MemberRefs,
+6 TypeRefs, 4 CustomAttributes, 0 TypeSpecs, 1 StandAloneSig, 1 AssemblyRef.
+
+```
+EnC Log (generation 1):
+ 1: AssemblyRef 0x23000002 Default (new AssemblyRef row)
+ 2: MemberRef 0x0a000006 Default (display-class Object::.ctor ref)
+ 3: MemberRef 0x0a000007 Default (Func`2..ctor, parent TypeSpec)
+ 4: MemberRef 0x0a000008 Default (Func`2.Invoke, parent TypeSpec)
+ 5: MemberRef 0x0a000009 Default (Object::.ctor TypeRef ref)
+ 6: TypeRef 0x01000007 Default (Object)
+ 7: TypeRef 0x01000008 Default (Func`2)
+ 8: TypeRef 0x01000009 Default (CompilerGeneratedAttribute)
+ 9: TypeSpec 0x1b000001 Default (Func`2)
+ a: StandAloneSig 0x11000002 Default (Compute's new locals signature)
+ b: TypeDef 0x02000003 Default <- the NEW TypeDef row, PLAIN row add
+ c: TypeDef 0x02000003 AddField <- parent: the NEW TypeDef
+ d: Field 0x04000001 Default <- capture field 'x'
+ e: MethodDef 0x06000001 Default (updated Compute body)
+ f: TypeDef 0x02000003 AddMethod
+10: MethodDef 0x06000003 Default (display class .ctor)
+11: TypeDef 0x02000003 AddMethod
+12: MethodDef 0x06000004 Default (lambda method b__0#1)
+13: Param 0x08000001 Default (updated Compute's param row re-emitted)
+14: MethodDef 0x06000004 AddParameter
+15: Param 0x08000002 Default (lambda param 'y')
+16: CustomAttribute 0x0c000005 Default ([CompilerGenerated] on the new TypeDef)
+17: NestedClass 0x29000001 Default <- trails the log
+
+EnC Map: token-sorted; contains TypeDef 0x02000003 (add), Field 0x04000001
+(add), MethodDef 1 (update) / 3,4 (add), Param 1 (update) / 2 (add),
+NestedClass 0x29000001 (add), the TypeRef/MemberRef/TypeSpec/StandAloneSig/
+AssemblyRef adds, CustomAttribute 0x0c000005 (add). NO entry for any Add*
+parent record.
+```
+
+Key facts the F# writer mirrors:
+
+- The new TypeDef row is logged as a **plain Default entry** (no special
+ "create type" operation) and is applied via ApplyTableDelta like any other
+ appended row. It MUST precede every AddField/AddMethod entry that names it
+ as the parent.
+- The TypeDef row's FieldList/MethodList columns are written as **0**
+ (Roslyn `DeltaMetadataWriter.GetFirstFieldDefinitionHandle/
+ GetFirstMethodDefinitionHandle` return `default` in EnC deltas — members
+ are linked through the EncLog pairs; mdv renders the columns "n/a (EnC)").
+- The NestedClass row is a plain Default entry trailing the log; it appears
+ in EncMap.
+- Generation 2 (body edit of the lambda added in generation 1) emits NO
+ TypeDef/Field/NestedClass rows: MethodDef 0x06000001 (update) and
+ 0x06000004 (update of the gen-1 added row) only — the added closure class
+ is simply part of the chained baseline.
+
+### F# emission shape (and divergences)
+
+`FSharpDeltaMetadataWriter.emitWithTypeDefinitions` accepts
+`TypeDefinitionRowInfo`/`NestedClassRowInfo` lists. EncLog group order is the
+established F# one (Module, new TypeDef rows, AddField pairs, method entries,
+parameter pairs, reference tables, property/event groups, NestedClass last).
+This differs from Roslyn's strict by-table interleaving (e.g. Roslyn logs the
+updated parent MethodDef *between* the AddField pair and the AddMethod pairs)
+— the CLR applier only requires that parents exist before Add* entries
+reference them and that each Add* pair stays adjacent, which both orders
+satisfy; the F# order was already validated by ApplyUpdate for member
+additions and is revalidated by the added-closure runtime tests.
+
+The emitter (`IlxDeltaEmitter`) detects added types by the closure name allocator's
+generation-suffix marker (`{base}@hotreload#g{N}_o{i}`, see
+`ClosureNameAllocator.isGenerationSuffixedClosureName`): a fresh-compile type
+with that name and no baseline TypeDef token allocates the next delta TypeDef
+row; its fields/methods register as added members parented to the new row
+(including INSTANCE fields — the existing-type instance-field restriction
+does not apply to a type introduced by the same delta). The row's Extends is
+remapped from the fresh compile (TypeRef via the reference remapper, TypeDef
+via the definition remapper). The new type token chains into the
+next-generation baseline `TypeTokens` under its full name, so later body
+edits of the added lambda resolve in place. The machinery is general; only
+the detection is closure-scoped (widening it to user types is covered in
+"User-defined new type definitions" below).
+
+Validated end-to-end at runtime
+(`ApplyUpdate succeeds for added lambda creating a new closure class`): a
+2-lambda member gains a third (capture-free) lambda → the generation-1 delta
+carries the new TypeDef + 3 AddMethod pairs (.ctor/Invoke/.cctor — F#'s
+capture-free closures add a static singleton initializer, one more method
+than C#'s display class), AddParameter pair for Invoke, NestedClass row →
+MetadataUpdater.ApplyUpdate accepts → the new lambda executes. Generation 2
+body-edits the ADDED lambda: method updates only, no new TypeDef/Field rows
+(the closure class chained into the baseline), exactly like the C# gen-2
+reference.
+
+### Content-validated MemberRef/TypeSpec token passthrough
+
+Landing the runtime test exposed a pre-existing positional fragility: the
+emitter passed fresh-compile MemberRef/TypeSpec tokens through verbatim when
+the row id fit inside the baseline table. An added lambda changes the ORDER
+OF FIRST USE of references (e.g. `ListModule.Map` now precedes `Filter`), so
+the fresh rows no longer line up positionally — the delta's MethodSpec rows
+silently bound `Filter`/`Map` (swapped genericity) and
+the applied body threw BadImageFormatException.
+
+The fix mirrors the TypeReferenceTokens approach: the baseline now snapshots
+MemberRef row contents (name, decoded parent token, signature blob) and
+TypeSpec signature blobs from the assembly bytes
+(`FSharpEmitBaseline.MemberReferenceRows`/`TypeSpecSignatures`, read by the
+SRM-free ILBaselineReader, which also gained the previously missing
+InterfaceImpl row size — assemblies WITH interface implementations had every
+table offset past InterfaceImpl misread). The remapper then:
+
+1. trusts a positional row id only when the fresh row's content (with parent
+ and signature REMAPPED to baseline coordinates) matches the baseline row;
+2. otherwise searches the baseline table for a unique content match;
+3. otherwise appends a new MemberRef — or TypeSpec — row (always legal).
+
+Delta-appended MemberRef and TypeSpec rows chain into the next generation's
+`MemberReferenceRows`/`TypeSpecSignatures` so later compiles can
+validate/reuse them. Baselines without byte-derived snapshots (legacy
+construction paths) keep the historic positional behavior; an empty TypeSpec
+snapshot with a zero baseline row count is treated as validated (every fresh
+TypeSpec is genuinely new and is appended).
+
+### TypeSpec row emission (new generic instantiations)
+
+An added lambda whose closure class extends a generic instantiation with no
+matching baseline TypeSpec row (the common case: the first
+`FSharpFunc` of that shape in the assembly, e.g. a `List.filter`
+predicate added to a baseline that only has `int -> int` closures) appends a
+TypeSpec row to the delta, mirroring the recorded C# reference template
+above (gen-1 entry `9: TypeSpec 0x1b000001 Default`):
+
+- The row is a single #Blob signature column (ECMA-335 II.22.39); the blob
+ is taken from the fresh compile and remapped to baseline coordinates via
+ `remapTypeSpecBlobWith` (inner TypeDefOrRef coded indexes through the
+ entity remapper). TypeSpec coded indexes embedded in signature blobs
+ (TypeDefOrRefOrSpec tag 2) route through the same content-validated remap.
+- EncLog logs the row with the plain **Default** operation (applied via
+ ApplyTableDelta, like TypeRef/MemberRef adds); the row id appears in the
+ token-sorted EncMap.
+- The appended row id and signature chain into the next-generation
+ baseline's `TypeSpecSignatures`, so a later generation content-matches and
+ reuses the row (a generation-2 lambda adding yet another new instantiation
+ appends its TypeSpec row PAST the generation-1 row; validated by
+ `ApplyUpdate succeeds for added lambdas with new generic instantiations
+ across generations`).
+- Methods added by an earlier generation and re-emitted by a later delta
+ write their name/signature into the later delta's heaps (the baseline
+ handle cache only covers the on-disk assembly); fresh-compile heap offsets
+ were previously emitted for these rows and produced garbage references
+ ("Bad binary signature") once the blobs no longer coincided with baseline
+ content.
+
+Generic closure CLASSES (closures over generic methods) get their
+GenericParam rows (see "Generic edits" below); the historic
+fail-closed gate there is lifted.
+
+### Session wiring (watch flow)
+
+`FSharpHotReloadSession.AddProject` rebuilds the baseline from the on-disk
+assembly. The EnC CDI carries occurrence identities rather than name slots,
+so baseline capture derives `EncClosureNames` from those identities and the
+MethodDef names. An MVID-matched in-process capture is a consistency check,
+not the source of truth; disk-started watch sessions therefore reconstruct
+the same closure-name tables without process-local carry-over.
+
+Known divergences from the C# reference (deliberate this slice):
+
+- Custom attributes on the added TypeDef are not emitted (C# adds
+ `[CompilerGenerated]`). F# closure classes function without them; CA
+ emission for added types can reuse the existing CustomAttribute row path
+ later.
+- `Extends` pointing at a generic instantiation (`FSharpFunc`2` =
+ TypeSpec) resolves through the content-validated TypeSpec remap: it reuses
+ a matching baseline row, or appends a new TypeSpec row to the delta for a
+ genuinely NEW instantiation (see "TypeSpec row emission" above).
+- Added GENERIC closure classes (closures over generic methods) are
+ supported: the new TypeDef row gets GenericParam rows (see
+ "Generic edits" below). Constrained typars still fail closed.
+- The AsyncStateMachineAttribute synthesis heuristic (nested
+ `{method}@hotreload*` type) now additionally requires a `MoveNext` method,
+ so ordinary closure classes sharing the naming no longer pick up a spurious
+ attribute.
+
+### Classification (TypedTreeDiff)
+
+- `LambdaEdit.Added`-only sets (no capture-set changes) are allowed when the
+ runtime advertises `NewTypeDefinition` + `AddMethodToExistingType`;
+ otherwise `RudeEditKind.NotSupportedByRuntime` names the missing
+ capability (C# parity).
+- `LambdaEdit.Removed`-only sets are allowed at Baseline capabilities (C#
+ parity: deleted lambda bodies just become unreachable; the baseline
+ closure class stays in place, unused). Validated at runtime
+ (`ApplyUpdate succeeds for removed lambda leaving baseline closure
+ unused`): the delta is a plain set of method updates touching no TypeDef
+ rows, and the new behavior takes effect on apply.
+- `CaptureSetChanged` stays rude this slice (capture-field mapping is a
+ later slice).
+
+## Generic edits
+
+### C# reference EncLog (Roslyn EmitDifference, csharp_enc_reference `generics` scenarios)
+
+Four recorded scenarios (mdv outputs:
+`csharp_enc_reference/reference_mdv_generic_{method_update,class_update,class_add,method_add}.txt`):
+
+1. **Body edit of `T Identity(T x)`** (`generic_method_update`): MethodDef
+ 1 UPDATE + Param 1 update + a new StandAloneSig (locals use MVAR `!!0`) +
+ TypeRef/AssemblyRef adds. **NO GenericParam rows** — they are baseline rows
+ and are never re-emitted for updates; the re-emitted MethodDef row's
+ GenericParameters column renders nil.
+2. **Body edit of `Container.Get()`** (`generic_class_update`): MethodDef
+ UPDATE only. The body reaches `this.Value` through a **MemberRef parented
+ by the TypeSpec self-instantiation** `Container'1` (delta-appended
+ MemberRef + TypeSpec rows). No GenericParam rows.
+3. **Adding `T GetAgain()` to `Container`** (`generic_class_add`): the
+ ordinary `(TypeDef, AddMethod) + (MethodDef, Default)` pair; the added
+ method's signature simply uses VAR (`20-00-13-00` = HASTHIS, 0 params,
+ ret `!0`). **No GenericParam rows** for non-generic methods of generic
+ types.
+4. **Adding `T Identity(T x)` to a class** (`generic_method_add`): the
+ AddMethod and AddParameter pairs are followed by a **`GenericParam
+ 0x2a000001 Default`** EncLog entry (plain row add, operation 0); the row
+ appears in EncMap as an add. GenericParam row: Number=0, Flags=0,
+ Owner=the NEW MethodDef (TypeOrMethodDef coded index), Name='T'. The
+ delta's #~ header keeps GenericParam in the sorted mask.
+
+### Roslyn capability semantics mirrored in classification
+
+`AbstractEditAndContinueAnalyzer`: body updates in a generic context
+(`InGenericContext`: own arity > 0 or any containing type generic) require
+`GenericUpdateMethod` (else `UpdatingGenericNotSupportedByRuntime`); method
+additions in a generic context require `AddMethodToExistingType +
+GenericAddMethodToExistingType`; field additions require the static/instance
+field capability + `GenericAddFieldToExistingType`. See
+`docs/hot-reload-capabilities.md` for the F# gating implementation.
+
+### F# emission
+
+- **Generic body updates needed no emitter change**: `MethodDefinitionKey`
+ carries `GenericArity`, the II.23.2 signature walker copies VAR/MVAR
+ elements verbatim through the blob remapper, and member-of-generic-type
+ field access flows through the content-validated MemberRef/TypeSpec remap
+ (the TypeSpec self-instantiation pattern). Pinned by runtime tests
+ (`GenericEditTests`): generic module function and generic-class member
+ body edits ApplyUpdate and observe the edit via reflection with two
+ instantiations (int, string); a two-generation chain whose second edit
+ introduces a brand-new generic instantiation (`List.replicate<'T>`,
+ MethodSpec with an MVAR blob) also applies. A template test asserts a
+ generic body-update delta carries ZERO GenericParam rows.
+- **Added generic methods** emit one GenericParam row per type parameter
+ (`GenericParamRowInfo`: Number/Flags/Owner/Name per ECMA-335 II.22.20),
+ snapshotted from the fresh compile's SRM reader, owner = the new delta
+ MethodDef row, row ids continuing from the chained baseline GenericParam
+ row count, ordered by (owner coded index, number) to respect the table's
+ sort key. Logged as plain Default entries after the parameter pairs;
+ EncMap adds. Previously the added MethodDef row shipped WITHOUT its
+ GenericParam rows: ApplyUpdate accepted the delta but the method was
+ corrupt (`MakeGenericMethod` threw NullReferenceException, member access
+ BadImageFormatException) — there was no fail-closed gate.
+- **Added methods on generic types** need no GenericParam rows (template 3);
+ they worked once classification allowed them and are pinned by a runtime
+ test (VAR signature + TypeSpec-parented field access).
+- **Added generic closure classes** (an added lambda inside a generic member
+ mentioning 'T): the added-TypeDef path no longer fails closed on
+ generic closures; the new TypeDef row carries GenericParam rows (owner
+ tag TypeDef). Validated at runtime: a generic member with one baseline
+ lambda gains a second 'T-typed lambda, ApplyUpdate succeeds, both
+ instantiations observe the edit. (Members gaining their FIRST lambda still
+ fail closed — the occurrence mapping needs a baseline chain table for
+ the member; that constraint is orthogonal to generics.)
+- **Constrained typars on ADDED definitions emit GenericParamConstraint
+ rows**. C# reference template
+ ('generic_constraint_add', reference_mdv_generic_constraint_add.txt):
+ adding `void DisposeIt(T x) where T : IDisposable` logs
+ `GenericParamConstraint 0x2c000001 Default` immediately after the
+ `GenericParam 0x2a000001 Default` entry; EncMap lists both as adds; the
+ constraint row's Owner is the NEW GenericParam row and its Constraint the
+ interface TypeRef. The F# writer mirrors this (Owner-sorted rows continuing
+ from the chained baseline count, constraint types remapped through the
+ TypeRef/TypeDef/TypeSpec remappers); validated at runtime
+ (`ApplyUpdate succeeds for added generic function with constrained typar`:
+ reflection reports the IDisposable constraint on the live type parameter
+ and the constrained method executes). Flag-only constraints (`not struct`,
+ `new()`) live in the Flags column as before; F#-only constraints
+ (`equality`, `comparison`) have no IL encoding.
+- dotnet-watch topology: disk-started sessions (baseline reconstructed from
+ the on-disk dll + pdb after a full session reset) apply both a generic
+ body edit and an added generic function (runtime tests).
+
+### Honest scoping (stays rude / fail-closed)
+
+- SRTP/`inline` changes: already `InlineChange` rude (inline bodies are
+ statically expanded into callers the delta cannot reach).
+- Constraint changes on EXISTING members: `SignatureChange` rude
+ (typar-constraint digest comparison).
+- Statically-resolved instantiation changes that alter lowered shapes
+ surface through the existing lowered-shape classifiers (state machine /
+ lambda shape) or the emitter's fail-closed gates with precise messages.
+
+## Custom attribute rows
+
+### Added members
+
+The long-standing added-member gap is closed: members ADDED by a delta now carry their
+fresh-compile CustomAttribute rows. The C# reference templates show the
+pattern (`prop_add`: 4 CA rows — `[CompilerGenerated]`/`[DebuggerBrowsable]`
+on the backing field and accessors; `added lambda`: `[CompilerGenerated]` on
+the new TypeDef), recorded as plain Default EncLog entries / EncMap adds
+appended past the baseline CustomAttribute row count.
+
+F# emission (`IlxDeltaEmitter.buildCustomAttributeRows`):
+
+- Added METHODS were already walked by the method-row loop; their CA value
+ blobs now enter the DELTA blob heap (previously the fresh compile's heap
+ offset was reused, which is garbage against the baseline+delta layout —
+ reflection could not decode the attribute value).
+- Added FIELDS / PROPERTIES / EVENTS / TYPES are walked from the added-token
+ maps: each fresh-compile attribute row becomes a CustomAttribute row add
+ whose parent is the new delta row (`HCA_Field`/`HCA_Property`/`HCA_Event`/
+ `HCA_TypeDef`), with the constructor remapped through the content-validated
+ MemberRef reuse/append path and the value blob written to the delta heap.
+- All appended CA rows are ordered by the HasCustomAttribute parent coded
+ index (ECMA-335 II.22.10 sort key; Roslyn DeltaMetadataWriter parity) and
+ renumbered contiguously past the chained baseline row count.
+
+What F#'s lowering actually decorates (validated against a fresh fsc
+compile): module-value properties carry `[CompilationMapping(Value)]` and
+their startup-class backing fields `[DebuggerBrowsable(Never)]` (+
+`[CompilerGenerated]`/`[DebuggerNonUserCode]` on `init@`); auto-property
+accessors carry `[CompilerGenerated]`/`[DebuggerNonUserCode]` (the property
+row and the `Name@` backing field carry nothing — unlike C#'s
+`k__BackingField`); user attributes (e.g. `[]`) ride on the added
+member like any other row.
+
+Runtime evidence (component `AttributeEditTests`): an added module function
+with `[]` exposes the attribute (and its message — the
+delta-heap value blob decodes) through reflection after ApplyUpdate; an added
+module value's property reports `CompilationMapping(Value)`; added accessors
+report `[CompilerGenerated]`; the template test pins Default-op CA EncLog
+entries, parent-sorted physical rows, and Field-/Property-parented rows.
+
+Known scoping: CA rows on added PARAM rows are not emitted (F#'s lowering
+does not decorate parameters in the supported scenarios).
+
+### Attribute changes on existing members
+
+C# reference templates (csharp_enc_reference attribute scenarios,
+`reference_mdv_attr_{add,change,remove}.txt`):
+
+- `attr_add` (`[Description("x")]` added to an existing method): MethodDef +
+ Param row updates plus ONE appended CustomAttribute row (parent = the
+ existing MethodDef token; EncMap add).
+- `attr_change` (argument changes): the CustomAttribute row is UPDATED at its
+ EXISTING row id (EncLog Default at that id; EncMap update).
+- `attr_remove`: the row is UPDATED with all-nil columns — raw row bytes
+ `00000000 03000000 00000000` (Parent = nil MethodDef, Constructor = nil
+ MemberRef tag 3, Value = nil blob).
+
+Classification (`TypedTreeDiff`): `BindingSnapshot.AttributesDigest` captures
+the member's attributes structurally (attribute type compiled name,
+positional/named argument digests, getter/setter routing, explicit targets;
+order-sensitive). Previously attribute-only edits were INVISIBLE to the diff
+(no digest covered them) — the delta applied without the attribute change. A
+digest change on a matched binding now:
+
+- without `ChangeCustomAttributes`: `RudeEditKind.NotSupportedByRuntime`
+ naming the capability (FSHRDL016, `hotReloadAttributeChangeNotSupportedByRuntime`);
+- with the capability, when the attribute rows are MethodDef-parented (plain
+ members/constructors, module functions): an ordinary member update edit —
+ emission pairs the rows (below);
+- with the capability but Property/Event-parented rows (property/event
+ accessors, module VALUES — F# routes their attrs to the Property row):
+ fail closed (`RudeEditKind.Unsupported`, precise message) — the writer
+ cannot update Property/Event-parented CA rows yet.
+
+Emission (`buildCustomAttributeRows` + `FSharpEmitBaseline.CustomAttributeRows`):
+the baseline now snapshots CustomAttribute row contents (decoded parent/ctor
+tokens + value blob) from the assembly bytes (SRM-free ILBaselineReader,
+which also fixed the simplified HasCustomAttribute coded-index width to the
+full 22-table ECMA set), chained with delta-emitted rows per generation. For
+each updated method the fresh compile's attributes pair IN ORDER against the
+baseline rows of that parent:
+
+- content-identical sets (remapped ctor token + value bytes) emit NOTHING —
+ this also retires the historic behavior of appending duplicate CA rows on
+ every body update of an attributed method;
+- changed attributes UPDATE the baseline row in place;
+- extra fresh attributes append rows (renumbered past the chained count,
+ parent-coded-index sorted);
+- extra baseline rows are ZEROED with the exact template encoding.
+
+The synthesized AsyncStateMachineAttribute path composes: when the fresh
+compile omits the attribute, pairing zeroes the stale baseline row and the
+synthesis appends the row pointing at the renamed state machine type.
+
+Runtime evidence (`AttributeEditTests`): attribute add/change/remove on an
+existing module function ApplyUpdate and reflection observes exactly the new
+attribute state (`Seq.exactlyOne` pins the absence of duplicated rows);
+template tests pin the in-place row id and the zeroed columns; negative
+tests pin the ChangeCustomAttributes gate and the Property-parented
+fail-closed path. Legacy baselines without byte-derived CA snapshots keep
+the historic append-only behavior. Consolidation: a two-generation chain
+(gen 1 APPENDS the attribute row, gen 2 UPDATES the chained row in place —
+`exactlyOne` would catch a duplicate append) and a disk-started session
+(the CA row snapshot reconstructed from the on-disk dll) both apply.
+
+### Parameter metadata updates
+
+C# reference template (`reference_mdv_param_rename.txt`): renaming a
+parameter re-emits the MethodDef and Param rows as UPDATES at their existing
+row ids (EncMap updates, no adds); the Param row's Name column carries the
+NEW name in the delta string heap.
+
+F#: `BindingSnapshot.ParameterNames` (see docs/hot-reload-capabilities.md for
+the gating) classifies renames; emission resolves the baseline parameter NAME
+alongside its heap offset (`ParameterDefinitionMetadataHandles.Name`, read
+from the baseline #Strings heap) and reuses the baseline name offset only
+when the fresh name matches — a differing name writes the fresh name into the
+delta string heap, producing exactly the template's row-update shape.
+
+Runtime evidence (`ParameterEditTests`): ApplyUpdate accepts the Param row
+update and `MethodInfo.GetParameters()` observes the new name on the live
+assembly — provided the parameter info was not reflected on BEFORE the
+update (the runtime caches ParameterInfo per MethodInfo; a primed cache keeps
+the old name — parameter names are debugger-facing metadata). Negative tests
+pin the UpdateParameters gate and that self-identifier renames do not gate.
+
+## User-defined new type definitions
+
+### C# reference EncLog (csharp_enc_reference 'new_class', reference_mdv_new_class.txt)
+
+A new top-level class (field + ctor + method, implementing IDisposable) used
+from an edited method emits in generation 1: the new TypeDef row as a plain
+Default entry (top-level: EnclosingType nil, no NestedClass row), an AddField
+pair, three AddMethod pairs with their AddParameter pairs, the updated using
+method as MethodDef/Param row updates, and an **InterfaceImpl row as a plain
+Default entry trailing the log** (EncMap add — the table needed new writer
+support). Generation 2 body-edits a method of the added class: a plain
+MethodDef update of the generation-1 row, no type/field/interface rows.
+
+### Classification
+
+`compareEntities` (TypedTreeDiff): an ADDED entity gates on
+`NewTypeDefinition` (Roslyn parity; previously always `DeclarationAdded`
+rude). Allowed representations: **class, record, union, struct, enum,
+interface, delegate, module** (`EntitySnapshot.SupportsAddition`; enums,
+interfaces, delegates and modules are covered in the sections below;
+units of measure classify as added classes — their TypeDef carries
+MeasureAttribute and their uses erase). Type abbreviations and exotic
+representations stay
+`DeclarationAdded` rude with a precise message (surfaced through FSHRDL004,
+whose diagnostic now appends the diff detail). The allowed addition becomes a
+`SemanticEditKind.Insert` edit whose symbol path mirrors the IL name; the new
+type's member bindings (including ctors, explicit interface implementations
+and the SYNTHESIZED record/union members that would otherwise hit the
+member-addition or lowered-shape gates) are SKIPPED by the binding diff —
+they ride along with the single entity Insert edit. (Exception: MODULE
+bindings carry `ContainingEntity = None` and keep flowing through the
+long-standing module-function/module-value addition classification — see
+"Added modules" below.)
+
+### Emission
+
+- `DeltaBuilder` excludes added entities from baseline type-token resolution
+ (no baseline row exists); the emitter receives their names via
+ `FSharpSymbolChanges.addedEntitySymbols` and matches fresh-compile TypeDefs
+ dot-normalized ('+' vs '.' nesting).
+- `collectTypeMappings` allocates delta TypeDef rows for matched fresh types
+ through the added-TypeDef machinery (rows, AddField/AddMethod pairs,
+ GenericParam rows, CustomAttribute rows, NestedClass
+ rows for types declared inside modules). Types NESTED inside an added type
+ (union case classes, Tags holders, DebugTypeProxy companions) are detected
+ via their enclosing type's delta row (parents visit first).
+- **InterfaceImpl rows** (new writer table): one per interface of an added
+ type, Interface remapped through the TypeRef/TypeDef/TypeSpec remappers,
+ rows sorted by (Class, Interface coded index), plain Default EncLog entries
+ trailing the log, EncMap adds.
+- **MethodImpl rows** (new writer table): F# classes implement interfaces
+ explicitly (`interface X with`), so every implemented slot carries a
+ MethodImpl row — C# never shows them for implicit impls. Body/Declaration
+ remap through the MethodDef/MemberRef remappers; sorted by Class.
+- PropertyMap/EventMap rows of added types parent the NEW delta TypeDef row
+ (previously baseline-only lookup).
+- Added methods without IL bodies originally failed closed; abstract and
+ runtime-implemented members are now supported (see "Added interfaces and
+ delegates" below) — only extern/pinvoke stays fail-closed.
+
+### Runtime evidence (`NewTypeDefinitionTests`)
+
+- Added class implementing IDisposable, used from the edited function:
+ ApplyUpdate succeeds, the new type is instantiable through reflection, the
+ interface is assignable and callable; generation 2 body-edits the added
+ class's member in place (C# gen-2 parity).
+- Added record: synthesized accessors/comparers/equality work on the live
+ type (structural Equals validated), IComparable assignable, the using
+ function reads fields.
+- Added union: case constructors, Tag/IsCircle accessors work; nested case
+ classes flow as nested-in-added types; the Tags holder's literal constants
+ decode (Constant rows, see "Added enums and Constant rows" below).
+- Template test pins: TypeDef Default entry precedes the AddMethod pairs,
+ three AddMethod pairs, InterfaceImpl + MethodImpl Default entries and
+ EncMap adds. Negative tests pin the NewTypeDefinition gate and the
+ unsupported-representation message (type abbreviations). A disk-started
+ session (dotnet-watch topology) applies the added-class-with-interface
+ scenario.
+
+## Added modules
+
+The idiomatic F# new-type case (C# parity: adding a static class, which
+Roslyn supports). An added module lowers to a sealed abstract static class
+TypeDef; the addition spans the new TypeDef AND member additions on the
+EXISTING startup-class TypeDef in one delta.
+
+- **Classification**: module entities are snapshotted (namespaces are not —
+ they have no TypeDef) with a fixed representation digest and
+ `SupportsAddition = true`; an added module classifies as a
+ `NewTypeDefinition` Insert exactly like an added class. The entity map key
+ carries a `|module` marker because `module X` and `type X` can coexist (the
+ module compiles with a ModuleSuffix). Module BINDINGS keep flowing through
+ the long-standing module-function/module-value addition paths (they carry
+ `ContainingEntity = None`), so the value backing field and the startup
+ constructor resolve normally.
+- **Emission**: the added-TypeDef machinery applies unchanged — the module
+ TypeDef row is a plain Default entry preceding its AddMethod pairs; the
+ module value's property lands as a PropertyMap-parented AddProperty pair on
+ the NEW TypeDef; the `counter@`/`init@` backing fields and the startup
+ `.cctor` land as AddField/AddMethod pairs on the BASELINE startup TypeDef.
+ `DeltaBuilder`'s startup-constructor resolution matches every symbol path
+ PREFIX (the startup class is per implementation FILE while a value inside an
+ added or nested module carries the module segments in its path).
+- **Runtime evidence** (`NewTypeDefinitionTests`): added module with a
+ function and a mutable value called from an edited method — the initializer
+ is observed because the ADDED startup constructor runs lazily on first
+ access (C# EnC semantics; had the startup class already been initialized the
+ value would read default(T)); generation 2 body-edits the added module's
+ function in place with state preserved; an added module CONTAINING a nested
+ module (NestedClass row against the new TypeDef) and a module added INSIDE
+ an existing module (NestedClass row against the baseline TypeDef) both
+ apply; disk-started session variant; template test pins the static-class
+ shape (no InterfaceImpl rows, 5 AddMethod / 2 AddField pairs, AddProperty).
+
+## Added enums and Constant rows
+
+C# reference template ('new_enum', reference_mdv_new_enum.txt): the added
+enum's TypeDef row is a plain Default entry; `value__` + each member are
+AddField pairs; one **Constant row per literal member trails the
+generation-1 log** as a plain Default entry (EncMap add). Constant row
+columns (ECMA-335 II.22.9): Type = 1-byte ELEMENT_TYPE code (e.g. 0x08 I4,
+physically a little-endian u2 whose high byte is the zero padding), Parent =
+the new Field row through the HasConstant coded index (tag Field=0), Value =
+a blob in the DELTA blob heap.
+
+- **Writer**: `ConstantRowInfo` (TypeCode/Parent/Value) through
+ DeltaMetadataTypes/Tables/Serializer and the SRM shadow writer (SRM's
+ `AddConstant` infers the ELEMENT_TYPE from the boxed value, so the blob is
+ decoded per type code for parity). Rows are ordered by parent Field row id
+ (the HasConstant sort key) and numbered past the chained baseline Constant
+ row count.
+- **The emitter collects Constant rows for EVERY added field** whose
+ fresh-compile FieldDef carries a default value: enum members, union Tags
+ holder constants (this CLOSED a latent gap — added unions previously shipped
+ their Tags literals withOUT Constant rows and the constants did not decode),
+ and `[]` module values.
+- **Runtime evidence**: added enum consumed by an edited function
+ (Enum.Parse/GetName/GetRawConstantValue decode the Constant rows on the
+ live type); added `[]` module value (the non-enum Constant case on
+ an EXISTING TypeDef). CoreCLR reflection enumerates an EnC-added literal
+ field TWICE (the EnC AddField path creates a FieldDesc enumerated as
+ RtFieldInfo while PopulateLiteralFields' metadata walk produces MdFieldInfo;
+ a baseline literal has no FieldDesc so the paths never overlap) and only
+ the MdFieldInfo implements GetRawConstantValue — a runtime quirk the test
+ pins, not delta corruption.
+
+## Added interfaces and delegates: bodiless methods
+
+C# reference templates ('new_interface'/'new_delegate'): Roslyn emits
+bodiless members as ordinary AddMethod pairs whose MethodDef rows carry
+**RVA 0x00000000** — the added interface's abstract slot (Abstract|Virtual)
+and the added delegate's `.ctor`/`Invoke`/`BeginInvoke`/`EndInvoke`
+(ImplAttributes 0x03 = Runtime). F# delegates emit the same four
+runtime-managed members.
+
+- The historic "bodiless added methods fail closed" gate now EXEMPTS added
+ methods that are abstract or runtime-implemented: no IL chunk enters the
+ delta and the MethodDef row's RVA column stays 0. Extern/pinvoke methods
+ keep failing closed precisely (they would also need ImplMap rows).
+- **Runtime evidence** (`NewTypeDefinitionTests`): an added interface
+ implemented by an added class is consumed from an edited body through
+ interface dispatch (the InterfaceImpl row's Interface coded index points at
+ the NEW delta TypeDef, the MethodImpl row's Declaration at the NEW bodiless
+ MethodDef); an added delegate is constructed inside an added module (the
+ synthesized closure class flows through the nested-in-added path), invoked
+ from the edited function, and `Delegate.CreateDelegate` over the live added
+ method dispatches through the runtime-implemented Invoke.
+
+### Honest scoping (stays rude / fail-closed)
+
+- Type abbreviations: `DeclarationAdded` rude with the precise
+ supported-representation list (fully erased — no TypeDef). Units of measure
+ are NOT rude: a `[] type` compiles to a sealed class TypeDef
+ (MeasureAttribute) and classifies as an ordinary added class; its uses
+ erase (runtime test pins ApplyUpdate + the live measure TypeDef).
+- Attributes ON InterfaceImpl rows (F# allows them) are not emitted.
+- ClassLayout (explicitly sized/packed added types), FieldLayout
+ (`[]`), FieldMarshal (interop marshalling) and FieldRVA
+ (static data blobs) have no writer support; the added-type/added-field
+ paths fail closed PRECISELY on each (negative test: explicit-layout struct
+ names FieldLayout). DeclSecurity likewise has no writer support.
+- Constant rows are emitted for added FIELDS only; added PARAMS with default
+ values (`[]`) do not carry their Param-
+ parented Constant rows yet.
+- Extern/pinvoke added methods (bodiless but needing ImplMap): fail closed
+ precisely at the bodiless gate.
+
+## Known gaps / later slices
+
+- Attribute changes on PARAMETERS (e.g. adding `[]`) are not
+ classified yet (the attribute digest covers member-level attributes only);
+ the Param row's flags/attributes re-emit from the fresh compile when the
+ member updates for other reasons.
+- mdv renders `` after every non-empty member-list range in EnC
+ generations by convention (member lists are associated via EncLog); this is
+ a rendering artifact, not delta corruption — Roslyn deltas render the same.
diff --git a/docs/hot-reload-quickstart.md b/docs/hot-reload-quickstart.md
new file mode 100644
index 00000000000..ffdc15b1578
--- /dev/null
+++ b/docs/hot-reload-quickstart.md
@@ -0,0 +1,157 @@
+# Trying F# Hot Reload
+
+Edit a **running** F# app and watch the change apply in place — no restart, state preserved.
+This guide takes you from two `git clone`s to a live hot-reload session. Budget ~45–60
+minutes, most of it waiting on the two builds.
+
+You need: git, bash (macOS/Linux; Windows works with the `.cmd` equivalents), and ~10 GB of
+disk for the two builds. You do **not** need any .NET SDK preinstalled — both repos bootstrap
+their own.
+
+## Fast path (one command)
+
+Clone this repo and run the setup script. It does steps 1-3 below (build the compiler, clone +
+build the SDK, sync them) in one go:
+
+```bash
+git clone --branch hot-reload-v2 --single-branch https://github.com/NatElkins/fsharp fsharp-hotreload
+cd fsharp-hotreload
+./docs/hot-reload-setup.sh
+```
+
+When it finishes (~45-60 min, almost all of it the two builds), jump to **step 4** to start a
+session. The manual steps 1-3 are spelled out next if you'd rather run them yourself, are on
+Windows, or want to understand what the script does.
+
+## 1. Build the compiler (this repo, branch `hot-reload-v2`)
+
+```bash
+git clone --branch hot-reload-v2 --single-branch https://github.com/NatElkins/fsharp fsharp-hotreload
+cd fsharp-hotreload
+./build.sh -c Debug # bootstraps a repo-local SDK, then builds; ~15-25 min first time
+cd ..
+```
+
+## 2. Build the SDK with the F#-aware dotnet-watch (branch `fsharp-hotreload-watch-v2`)
+
+```bash
+git clone --branch fsharp-hotreload-watch-v2 --single-branch https://github.com/NatElkins/sdk sdk-hotreload
+cd sdk-hotreload
+# Two steps: (1) a full build to restore the whole repo -- it will stop with errors from a few
+# unrelated test projects, which is expected and harmless; (2) build only the runnable SDK
+# layout, which compiles no test projects and reuses the restore from step 1:
+./build.sh -c Debug /p:RunAnalyzers=false || true # ~15-25 min first time
+./build.sh --build --projects "$PWD/src/Layout/redist/redist.csproj" -c Debug /p:RunAnalyzers=false
+cd ..
+```
+
+This produces a complete, runnable .NET CLI at `sdk-hotreload/artifacts/bin/redist/Debug/dotnet`.
+
+## 3. Point the SDK at the hot-reload compiler
+
+The SDK ships a stock F# compiler; replace its `FSharp.Compiler.Service` (and the matching
+`FSharp.Core`) with the ones you just built. From the directory holding both clones:
+
+```bash
+./fsharp-hotreload/docs/hot-reload-sync-fcs.sh
+```
+
+That copies the two DLLs into `sdk-hotreload/artifacts/bin/redist/Debug/dotnet/sdk/*/FSharp/`.
+Re-run it any time you rebuild the compiler. (To do it by hand instead, copy
+`FSharp.Compiler.Service.dll` and `FSharp.Core.dll` from
+`fsharp-hotreload/artifacts/bin/FSharp.Compiler.Service/Debug/netstandard2.0/` into that
+`FSharp/` folder.)
+
+## 4. Create a demo app and start watching
+
+Put the freshly built CLI on your `PATH` by sourcing the SDK repo's `dogfood` script. It sets
+`DOTNET_ROOT` and the SDK resolver for you, which is the supported way to run a locally built
+SDK:
+
+```bash
+source sdk-hotreload/eng/dogfood.sh # Windows: sdk-hotreload\eng\dogfood.cmd
+
+# dogfood.sh leaves you in the SDK's test folder, so move somewhere you want the demo:
+mkdir -p ~/hot-reload-demo && cd ~/hot-reload-demo
+dotnet new console -lang F#
+```
+
+Open the `.fsproj` and add one line inside the `` — this is the hot reload
+opt-in flag:
+
+```xml
+$(OtherFlags) --test:HotReloadDeltas
+```
+
+Replace `Program.fs` with something that loops, so you can see updates land:
+
+```fsharp
+type Greeter() =
+ let mutable count = 0
+
+ member _.Message() =
+ count <- count + 1
+ sprintf "hello (count: %d)" count
+
+let greeter = Greeter()
+
+while true do
+ printfn "%s" (greeter.Message())
+ System.Threading.Thread.Sleep(1000)
+```
+
+Run it:
+
+```bash
+dotnet watch run --non-interactive
+```
+
+After the build, the counter starts ticking once a second. The
+`F# hot reload session prestarted` confirmation is only printed when you set
+`DOTNET_WATCH_TRACE_FSHARP_HOTRELOAD=1` first, so on a normal run you won't see it. The real
+signal that reloads are landing is the counter in step 5: it keeps climbing across edits instead
+of resetting to 1.
+
+## 5. Edit and save — the counter is your proof
+
+The `count` keeps climbing through every successful in-place apply. If it resets to 1, the
+process restarted. Try these, saving after each:
+
+1. **Change the string** — `"hello"` → anything. Applies in ~2–3 s.
+2. **Add a lambda** (the thing F# hot reload could never do before):
+ ```fsharp
+ member _.Message() =
+ count <- count + 1
+ let parts = [ "hot"; "reload" ] |> List.map (fun s -> s.ToUpper())
+ sprintf "%s (count: %d)" (String.concat " " parts) count
+ ```
+ This synthesizes a brand-new closure class and patches it into the running process.
+3. **Edit the lambda's body** — `ToUpper` → `ToLower`. The added closure is updated in place.
+4. **Add a member, a `let mutable` field, a property, a whole new type or module** and use it
+ from `Message()` — all apply in place.
+5. **Save with only a comment/whitespace change** — detected as a no-op, nothing happens.
+6. **Break it on purpose** — change `Message()` to take a parameter. That's a *rude edit*
+ (same as C#): the console names the reason and the app restarts cleanly (counter resets).
+
+## What's supported
+
+Method-body edits (including closures, `async`, resume-point-stable `task`, generics);
+adding methods, functions, module values, fields, properties, events, and new types
+(classes/records/unions/structs/modules/enums/interfaces/delegates); attribute edits;
+parameter renames; multiple projects in one watch session. The design docs in this folder
+(`hot-reload-architecture.md` is the entry point) record exactly what stays a rude edit
+and why — generally the same set as C#.
+
+## Troubleshooting
+
+- **Everything restarts instead of applying**: confirm the `OtherFlags` line is in the
+ `.fsproj` (the on-disk build and the in-memory compiles must both carry the flag), and that
+ step 3's sync (`hot-reload-sync-fcs.sh`) actually overwrote the SDK's
+ `FSharp/FSharp.Compiler.Service.dll`.
+- **Want to see why a specific edit restarted**: `export DOTNET_WATCH_TRACE_FSHARP_HOTRELOAD=1`
+ before `dotnet watch run` — the F# service logs its classification (e.g. which rude edit or
+ missing runtime capability) for every emit.
+- **First edit is slow**: the first delta includes compiler-service warm-up; subsequent edits
+ are ~2–3 s. (Reducing this is roadmapped — the bulk is an FCS recheck that composes with
+ the in-flight compiler caching work in
+ [dotnet/fsharp#19267](https://github.com/dotnet/fsharp/pull/19267).)
diff --git a/docs/hot-reload-rude-edits.md b/docs/hot-reload-rude-edits.md
new file mode 100644
index 00000000000..a4a974d537a
--- /dev/null
+++ b/docs/hot-reload-rude-edits.md
@@ -0,0 +1,27 @@
+# F# hot reload rude-edit diagnostics
+
+F# hot reload reports an `FSHRDL` diagnostic when an edit cannot be applied safely to the running process. The application is rebuilt and restarted instead of applying a delta that could leave it in an invalid state.
+
+The diagnostic message identifies the affected declaration and the reason for the restart. These codes are owned by the F# compiler and are separate from Roslyn's `ENC` diagnostic namespace.
+
+| Code | Meaning | What to do |
+| --- | --- | --- |
+| `FSHRDL001` | A member signature changed. | Undo the signature change to apply in place, or allow the rebuild and restart. |
+| `FSHRDL002` | An `inline` annotation changed. | Allow the rebuild and restart. |
+| `FSHRDL003` | A type representation or layout changed. | Allow the rebuild and restart. |
+| `FSHRDL004` | A declaration was added in a shape the runtime cannot add. | Allow the rebuild and restart. |
+| `FSHRDL005` | A declaration was removed. | Allow the rebuild and restart. |
+| `FSHRDL006` | A virtual, abstract, or override member was added. | Allow the rebuild and restart. |
+| `FSHRDL007` | A constructor was added. | Allow the rebuild and restart. |
+| `FSHRDL008` | A user-defined operator was added. | Allow the rebuild and restart. |
+| `FSHRDL009` | An explicit interface implementation was added. | Allow the rebuild and restart. |
+| `FSHRDL010` | A member was added to an interface. | Allow the rebuild and restart. |
+| `FSHRDL011` | A field was added in a shape the runtime cannot add. | Allow the rebuild and restart. |
+| `FSHRDL012` | A lambda's lowered shape changed incompatibly. | Allow the rebuild and restart. |
+| `FSHRDL013` | A state machine's resumable or hoisted layout changed incompatibly. | Keep the existing resume-point and captured-value layout, or allow the rebuild and restart. |
+| `FSHRDL014` | A query expression's lowered shape changed incompatibly. | Allow the rebuild and restart. |
+| `FSHRDL015` | A synthesized compiler declaration changed incompatibly. | Allow the rebuild and restart. |
+| `FSHRDL016` | The runtime did not advertise a capability required by the edit. | Update the runtime if a newer version supports the capability, or allow the rebuild and restart. |
+| `FSHRDL099` | The edit is unsupported for another fail-closed reason. | Follow the detailed message and allow the rebuild and restart. |
+
+These diagnostics are intentionally fail closed. If the compiler cannot prove that an edit is safe, it requests a restart and leaves the running application unchanged.
diff --git a/docs/hot-reload-setup.sh b/docs/hot-reload-setup.sh
new file mode 100755
index 00000000000..359197a59f5
--- /dev/null
+++ b/docs/hot-reload-setup.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+#
+# One-shot setup for trying F# hot reload. From a clone of this repo, this:
+# 1. builds the F# compiler (this repo),
+# 2. clones + builds the F#-aware .NET SDK (dotnet-watch),
+# 3. syncs the hot-reload compiler into the SDK redist.
+#
+# Then start a session with:
+# source /eng/dogfood.sh # puts the built `dotnet` on PATH
+# dotnet watch run # in an F# project that opts in (see hot-reload-quickstart.md)
+#
+# Budget ~30-45 minutes, almost all of it the two first-time builds. Re-runs are incremental.
+#
+# Overridable via env: SDK_REMOTE, SDK_BRANCH, SDK_ROOT.
+#
+set -euo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+fsharp_root="$(cd "$script_dir/.." && pwd)"
+workdir="$(cd "$fsharp_root/.." && pwd)"
+
+sdk_remote="${SDK_REMOTE:-https://github.com/NatElkins/sdk}"
+sdk_branch="${SDK_BRANCH:-fsharp-hotreload-watch-v2}"
+sdk_root="${SDK_ROOT:-$workdir/sdk-hotreload}"
+
+echo "==> [1/3] Building the F# compiler"
+echo " $fsharp_root"
+( cd "$fsharp_root" && ./build.sh -c Debug )
+
+echo "==> [2/3] Cloning + building the F#-aware SDK"
+echo " $sdk_root ($sdk_branch)"
+if [[ ! -d "$sdk_root/.git" ]]; then
+ git clone --branch "$sdk_branch" --single-branch "$sdk_remote" "$sdk_root"
+fi
+# Two steps: (1) a full build to restore the whole repo -- it stops with errors from a few
+# unrelated test projects on this merged branch, which is expected and harmless; (2) build only
+# the redist (the runnable SDK layout), which compiles no test projects and reuses the restore.
+( cd "$sdk_root" && ./build.sh -c Debug /p:RunAnalyzers=false || true )
+( cd "$sdk_root" && ./build.sh --build --projects "$sdk_root/src/Layout/redist/redist.csproj" -c Debug /p:RunAnalyzers=false )
+
+echo "==> [3/3] Syncing the hot-reload compiler into the SDK redist"
+"$script_dir/hot-reload-sync-fcs.sh" "$fsharp_root" "$sdk_root"
+
+cat < Setup complete.
+
+Start a hot-reload session:
+
+ source "$sdk_root/eng/dogfood.sh"
+ mkdir -p ~/hot-reload-demo && cd ~/hot-reload-demo
+ dotnet new console -lang F#
+ # add this line inside the .fsproj :
+ # \$(OtherFlags) --test:HotReloadDeltas
+ dotnet watch run --non-interactive
+
+Then edit Program.fs and watch changes apply in place. See docs/hot-reload-quickstart.md
+(step 4 onward) for a demo program and exactly what to try editing.
+EOF
diff --git a/docs/hot-reload-sync-fcs.sh b/docs/hot-reload-sync-fcs.sh
new file mode 100755
index 00000000000..d1d33d155f5
--- /dev/null
+++ b/docs/hot-reload-sync-fcs.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+#
+# Copies the locally built hot-reload FSharp.Compiler.Service (and the matching FSharp.Core)
+# into a locally built .NET SDK redist, so `dotnet watch` resolves the hot-reload compiler
+# instead of the stock one the SDK ships. Safe to re-run.
+#
+# Usage: hot-reload-sync-fcs.sh [ ]
+# Defaults: this script's repo for the compiler, and a sibling "sdk-hotreload" for the SDK.
+#
+set -euo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+fsharp_root="${1:-$(cd "$script_dir/.." && pwd)}"
+sdk_root="${2:-$(cd "$script_dir/../.." && pwd)/sdk-hotreload}"
+
+fcs_bin="$fsharp_root/artifacts/bin/FSharp.Compiler.Service/Debug/netstandard2.0"
+redist="$sdk_root/artifacts/bin/redist/Debug/dotnet"
+
+if [[ ! -f "$fcs_bin/FSharp.Compiler.Service.dll" ]]; then
+ echo "error: built FCS not found at $fcs_bin" >&2
+ echo " build the F# repo first: (cd '$fsharp_root' && ./build.sh -c Debug)" >&2
+ exit 1
+fi
+
+if [[ ! -x "$redist/dotnet" ]]; then
+ echo "error: SDK redist not found at $redist" >&2
+ echo " build the SDK repo first (see hot-reload-quickstart.md, step 2)" >&2
+ exit 1
+fi
+
+shopt -s nullglob
+synced=0
+for fsharp_dir in "$redist"/sdk/*/FSharp; do
+ cp -f "$fcs_bin/FSharp.Compiler.Service.dll" "$fsharp_dir/"
+ cp -f "$fcs_bin/FSharp.Core.dll" "$fsharp_dir/"
+ echo "synced hot-reload FCS -> $fsharp_dir"
+ synced=1
+done
+
+if [[ "$synced" != 1 ]]; then
+ echo "error: no FSharp directory found under $redist/sdk/*/FSharp" >&2
+ exit 1
+fi
+
+echo "done. now: source '$sdk_root/eng/dogfood.sh' and run 'dotnet watch run' in an F# project."
diff --git a/docs/hot-reload-tgro-closure-matrix.md b/docs/hot-reload-tgro-closure-matrix.md
new file mode 100644
index 00000000000..68a8818dc14
--- /dev/null
+++ b/docs/hot-reload-tgro-closure-matrix.md
@@ -0,0 +1,140 @@
+# Hot Reload: T-Gro Feedback Closure Matrix
+
+Last updated: 2026-03-02
+Source comments: NatElkins/fsharp#1 (T-Gro top-level review comments, 2026-02-20)
+
+## Goal
+
+Track each major review concern with objective status and evidence so follow-up work is explicit and review risk remains scoped.
+
+## Status legend
+
+- Addressed: implemented and guarded by tests/scripts.
+- Partially addressed: meaningful progress, but boundary/risk item still open.
+- Open: design/implementation work still required.
+
+## Matrix
+
+### 1) Plugin boundary / layering safety-first
+
+- Status: **Addressed**
+- Evidence:
+ - `fsc` emit path routes through a generic emit hook abstraction rather than direct hot reload APIs: `src/Compiler/Driver/fsc.fs`.
+ - Hot reload hook bootstrap remains explicit-only (`--test:HotReloadDeltas`) and wires hook behavior per compilation invocation: `src/Compiler/Driver/CompilerEmitHookBootstrap.fs`.
+ - Ambient compiler emit-hook mutation has been removed; hook resolution is now explicit-config-only with no process-wide mutable fallback: `src/Compiler/Driver/CompilerEmitHookState.fs`.
+ - Hot reload service no longer mutates compiler-wide hook state during session start/end: `src/Compiler/Service/service.fs`.
+- Checker compile now injects explicit hook-only enablement (`--test:HotReloadHook`) while a session is active, preserving synthesized-name replay without ambient mutable hooks: `src/Compiler/Service/service.fs`, `src/Compiler/Driver/CompilerOptions.fs`.
+ - `fsc` still does not import hot reload implementation modules directly and resolves hooks through the bootstrap boundary adapter: `src/Compiler/Driver/fsc.fs`, `src/Compiler/Driver/CompilerEmitHookBootstrap.fs`.
+ - Architecture guards enforce explicit-only/no-ambient wiring boundaries: `tests/FSharp.Compiler.Service.Tests/HotReload/ArchitectureGuardTests.fs`.
+ - Output parity regression proves non-hot-reload artifacts stay unchanged when the flag is toggled: `tests/FSharp.Compiler.Service.Tests/HotReload/HotReloadCheckerTests.fs` (`Compiler outputs stay byte-identical when hot reload capture flag is toggled`).
+
+### 2) Remove IlxGen-specific hot reload naming hook drift
+
+- Status: **Addressed**
+- Evidence:
+ - `hotReloadIlxName` removed; centralized naming wrappers now enforce one path in `IlxGen`.
+ - Naming-path guard script enforces wrapper-only direct generator access: `tests/scripts/check-ilxgen-name-path.sh`.
+
+### 3) Extract checker-owned hot reload state
+
+- Status: **Addressed**
+- Evidence:
+ - `FSharpHotReloadService` owns session orchestration and state transitions; checker delegates through thin APIs: `src/Compiler/Service/service.fs`.
+
+### 4) Keep normal compilation naming semantics upstream-equivalent when hot reload is off
+
+- Status: **Addressed**
+- Evidence:
+ - `CompilerGlobalState` non-map path uses file-index + start-line + 1-based increment semantics: `src/Compiler/TypedTree/CompilerGlobalState.fs`.
+
+### 5) opDigest wildcard catch-all silent-risk
+
+- Status: **Addressed**
+- Evidence:
+ - `opDigest` is wildcard-free.
+ - Guard test enforces no `| _ ->` in `opDigest`: `tests/FSharp.Compiler.Service.Tests/HotReload/ArchitectureGuardTests.fs`.
+
+### 6) State-machine/query string heuristics
+
+- Status: **Addressed**
+- Evidence:
+ - Declaring-type string heuristic removed.
+ - Operation-name list heuristics were removed from lowered-shape collection/classification (`isLikelyQueryOperationName` / `isLikelyStateMachineOperationName` no longer exist): `src/Compiler/TypedTree/TypedTreeDiff.fs`.
+ - Lowered-shape digests are now structural-only (`formatLoweredShapeDigest` emits `struct=[...]`), and synthesized classification uses structural evidence plus the explicit `MoveNext` sentinel: `src/Compiler/TypedTree/TypedTreeDiff.fs`.
+ - Structural trait-call fingerprints (`traitConstraintShapeDigest`) remain in `TraitCall`/`WitnessArg` paths, preserving query-lowering evidence without name-list matching: `src/Compiler/TypedTree/TypedTreeDiff.fs`.
+ - Architecture guards now enforce structural-only lowered-shape classification and explicit absence of operation-name heuristics: `tests/FSharp.Compiler.Service.Tests/HotReload/ArchitectureGuardTests.fs`.
+ - Service regressions verify query-like/state-machine-like member names without lowered rewrites do not emit query/state-machine rude edits: `tests/FSharp.Compiler.Service.Tests/HotReload/TypedTreeDiffTests.fs`.
+ - Existing async/query lowered-shape edits are now explicitly locked to structural-only fallback (`LambdaShapeChange`) when no dedicated query/state structural marker is present, so classification no longer depends on operation-name lists: `tests/FSharp.Compiler.Service.Tests/HotReload/TypedTreeDiffTests.fs`.
+
+### 7) String-based symbol identity chain
+
+- Status: **Addressed**
+- Evidence:
+ - `TypedTreeDiff.SymbolId` now transports typed runtime signature identity (`RuntimeTypeIdentity`) for method parameters/return values instead of string signatures: `src/Compiler/TypedTree/TypedTreeDiff.fs`, `src/Compiler/TypedTree/TypedTreeDiff.fsi`.
+ - Typed-tree signature encoding now includes void/array/byref/native pointer identities and method generic type-variable ordinals, keeping symbol-side signatures structurally comparable to emitted IL signatures: `src/Compiler/TypedTree/TypedTreeDiff.fs`.
+ - `DeltaBuilder` now converts baseline `ILType`/`ILTypeSpec` signatures into the same typed `RuntimeTypeIdentity` model and performs structural identity matching in both pre-index and fallback disambiguation paths: `src/Compiler/HotReload/DeltaBuilder.fs`.
+ - Existing fail-closed behavior is preserved: incomplete/ambiguous runtime method identity still returns full-rebuild diagnostics rather than permissive token binding: `src/Compiler/HotReload/DeltaBuilder.fs`.
+ - Regression coverage updated to validate typed method-signature identity mapping and mismatch fail-closed behavior under the new typed identity path: `tests/FSharp.Compiler.Service.Tests/HotReload/DeltaBuilderTests.fs`.
+
+
+### 8) Manual metadata serialization evolution risk
+
+- Status: **Addressed**
+- Evidence:
+ - Delta metadata emission uses a single proven hand-written serializer that builds the table/heap streams from the shared row model: `src/Compiler/CodeGen/FSharpDeltaMetadataWriter.fs`, `src/Compiler/CodeGen/DeltaMetadataSerializer.fs`.
+ - Automated parity gate validates emitted deltas against `mdv` component output: `tests/scripts/check-hotreload-metadata-parity.sh`, `tests/FSharp.Compiler.ComponentTests/HotReload/MdvValidationTests.fs`.
+ - Serializer hardening remains in place (heap-offset validation + malformed index tests): `src/Compiler/CodeGen/DeltaMetadataSerializer.fs`, `tests/FSharp.Compiler.Service.Tests/HotReload/FSharpDeltaMetadataWriterTests.fs`.
+
+### 9) Large `IlxDeltaEmitter` single-function blast radius
+
+- Status: **Addressed**
+- Evidence:
+ - `emitDelta` now routes metadata row assembly through explicit helper phases (`buildMethodAndParameterRows`, `buildPropertyEventAndSemanticsRows`, `buildCustomAttributeRows`).
+ - Final payload assembly (`added/changed method projection`, `PDB delta`, `baseline apply`) now runs through dedicated `finalizeDeltaArtifacts` helpers (`buildAddedOrChangedMethods`, `buildDeltaToUpdatedMethodTokenMap`) instead of inline logic.
+ - Metadata reference remapping (`TypeRef`, `MemberRef`, `MethodSpec`, `AssemblyRef`, entity-token dispatch) is extracted into `createMetadataReferenceRemapper`: `src/Compiler/CodeGen/IlxDeltaEmitter.fs`.
+ - Definition-token remapping is extracted into `createDefinitionTokenRemapper` and consumed separately for definition/association resolution (`Property`/`Event`) so metadata-reference remap flow no longer carries definition-map dictionaries: `src/Compiler/CodeGen/IlxDeltaEmitter.fs`.
+ - Architecture guards now enforce both explicit emitter phases and remapper separation (`MetadataReferenceRemapContext` stays reference-focused while emit flow wires both remappers explicitly): `tests/FSharp.Compiler.Service.Tests/HotReload/ArchitectureGuardTests.fs`.
+
+
+### 10) HR files in core directories
+
+- Status: **Addressed**
+- Evidence:
+ - Hot reload namespaced modules live under `src/Compiler/HotReload/` (e.g., `DefinitionMap.fs`, `FSharpSymbolChanges.fs`).
+
+### 11) `isEnvVarTruthy` duplication
+
+- Status: **Addressed**
+- Evidence:
+ - Shared helper used from `Utilities/EnvironmentHelpers.fs`.
+
+### 12) ApplyUpdate setup duplication
+
+- Status: **Addressed**
+- Evidence:
+ - Shared test helper extracted in `tests/FSharp.Compiler.ComponentTests/HotReload/ApplyUpdateShared.fs`.
+
+### 13) Construct coverage breadth (Tier1/Tier2)
+
+- Status: **Addressed (baseline matrix added)**
+- Evidence:
+ - Runtime integration construct matrix tests cover Tier1 and Tier2 edit/apply scenarios: `tests/FSharp.Compiler.ComponentTests/HotReload/RuntimeIntegrationTests.fs`.
+
+### 14) Maintain `.fsi` stability relative to `main`
+
+- Status: **Partially addressed**
+- Evidence:
+ - Guard now enforces allowlist + mandatory hash-locking for every drifted `.fsi`: `tests/scripts/check-main-fsi-drift.sh`.
+ - Refresh helper added: `tests/scripts/refresh-main-fsi-drift-hashes.sh`.
+ - Reduced one main-relative signature drift by localizing hot-reload activity tag literals in `EditAndContinueLanguageService` and removing `Activity.fsi` from the allowlisted drift set (`10 -> 9` files).
+ - Removed hot-reload-specific `FSharpCheckProjectResults` signature exposure (`TypedImplementationFiles`, `HotReloadOptimizationData`) and switched service retrieval to non-public reflection so this branch no longer grows explicit hot-reload API surface in `FSharpCheckerResults.fsi`.
+ - Removed stale `FSharpCheckerResults.fsi` entries from the main-relative `.fsi` drift allowlist/hash lock once the file returned to parity with `origin/main`, reducing tracked drift surface to 8 files.
+- Remaining gap:
+ - The allowlisted drift set is still non-trivial and should be reduced through targeted refactors.
+
+## Validation performed for this update
+
+- `./.dotnet/dotnet build FSharp.sln -c Debug -v minimal`
+- `DOTNET_MODIFIABLE_ASSEMBLIES=debug COMPlus_ForceEnc=1 ./.dotnet/dotnet test --project tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj -c Debug --no-build -v minimal -- --filter-class "*HotReload*"` (`328` passed)
+- `DOTNET_MODIFIABLE_ASSEMBLIES=debug COMPlus_ForceEnc=1 ./.dotnet/dotnet test --project tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj -c Debug --no-build -v minimal -- --filter-class "*HotReload*"` (`110` passed)
+- `./tests/scripts/check-hotreload-metadata-parity.sh`
diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md
index 608978809b3..24a63ca3c31 100644
--- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md
+++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md
@@ -132,7 +132,10 @@
### Added
+* Add an internal typed-tree diff utility for future F# hot reload edit classification. It is not called by normal compilation. ([PR #20025](https://github.com/dotnet/fsharp/pull/20025))
* Added internal synthesized-name replay infrastructure for compiler-generated names, preserving normal compilation output while enabling future hot reload name stability work.
+* Added an experimental, internal, flag-gated in-process compile path for hot reload sessions. `FSHARP_HOTRELOAD_INPROCESS_COMPILE` refreshes the output assembly and PDB from the latest checked project before delta emission, while `FSHARP_HOTRELOAD_INCREMENTAL_EMIT` enables a nested per-file optimized-tree cache. ([PR #20031](https://github.com/dotnet/fsharp/pull/20031))
+* Added internal F# hot reload delta emitter and symbol matcher infrastructure with direct emitter test coverage. ([PR #20027](https://github.com/dotnet/fsharp/pull/20027))
* Added `FSharpMemberOrFunctionOrValue.IsPropertyAccessor` convenience property that returns true for compiler-generated property accessors (`get_X` / `set_X`). ([Issue #18157](https://github.com/dotnet/fsharp/issues/18157), [PR #19883](https://github.com/dotnet/fsharp/pull/19883))
* Added warning FS3884 when a function or delegate value is used as an interpolated string argument. ([PR #19289](https://github.com/dotnet/fsharp/pull/19289))
* Symbols: add ObsoleteDiagnosticInfo ([PR #19359](https://github.com/dotnet/fsharp/pull/19359))
@@ -140,6 +143,7 @@
* Add `#version;;` directive to F# Interactive to display version and environment information. ([Issue #13307](https://github.com/dotnet/fsharp/issues/13307), [PR #19332](https://github.com/dotnet/fsharp/pull/19332))
* Add diagnostic FS3889 when a namespace and a type have the same fully-qualified name in the same assembly, replacing the misleading FS0247 "namespace and a module" error. ([Issue #17827](https://github.com/dotnet/fsharp/issues/17827), [PR #19802](https://github.com/dotnet/fsharp/pull/19802))
* Debug: rework for expressions stepping ([PR #19894](https://github.com/dotnet/fsharp/pull/19894))
+* Experimental hot reload support: `--test:HotReloadDeltas` baseline capture in fsc, EnC metadata/IL/PDB delta emission, rude-edit detection, and an `FSharpChecker` session API (`CreateHotReloadSession`, with `AddProject`/`EmitDelta`/`Commit`/`Discard`). The further `--test:HotReloadClassStateMachines` opt-in emits resumable computation expressions as reference types so adding, removing, or reordering a `let!`/`do!`/`yield` can be applied rather than treated as a rude edit. Both flags are off by default and flag-off compilation is unchanged. ([Issue #11636](https://github.com/dotnet/fsharp/issues/11636), [PR #19941](https://github.com/dotnet/fsharp/pull/19941))
* Debug: rework conditional erasure, fix stepping over literals ([PR #19897](https://github.com/dotnet/fsharp/pull/19897))
* Record spreads ([RFC FS-1151](https://github.com/fsharp/fslang-design/pull/805), [PR #18927](https://github.com/dotnet/fsharp/pull/18927), [PR #20206](https://github.com/dotnet/fsharp/pull/20206))
* Debug: fix if and match condition sequence points ([PR #19932](https://github.com/dotnet/fsharp/pull/19932))
@@ -150,6 +154,8 @@
* Add internal `ResetCompilerGeneratedNameState` to `CompilerGlobalState` name generators so warm-checker re-compilation can produce fresh-process-identical generated names. ([PR #20017](https://github.com/dotnet/fsharp/pull/20017))
* Add internal ECMA-335 Edit-and-Continue metadata delta writer to AbstractIL. ([PR #20019](https://github.com/dotnet/fsharp/pull/20019))
* Add Roslyn-format EnC CustomDebugInformation codec and portable PDB method CDI emission support to AbstractIL. ([PR #20018](https://github.com/dotnet/fsharp/pull/20018))
+* Add an experimental `FSharpChecker` hot reload session API with per-project baselines, capability-gated delta emission, and `Commit`/`Discard` transaction semantics. Off by default behind `--test:HotReloadDeltas`. ([Issue #11636](https://github.com/dotnet/fsharp/issues/11636), [PR #20030](https://github.com/dotnet/fsharp/pull/20030))
+* Add internal hot reload baseline reading for recorded EnC state and synthesized-name snapshot PDB data. ([PR #20026](https://github.com/dotnet/fsharp/pull/20026))
* Support for the `` XML documentation tag: at compile time, documentation is copied from an external XML file selected by an XPath query and emitted into the generated documentation file. `` remains unsupported. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19186](https://github.com/dotnet/fsharp/pull/19186))
* Expand `` at tooling time. In IDE tooltips, completion, and signature help, documentation is inherited from base classes, interfaces, overridden members, and constructors (matched by parameter signature). The FCS Symbols API (`FSharpSymbol.XmlDoc`) additionally resolves explicit `cref` targets, but does not expand constructor inheritance. The compiler emits the tag verbatim into generated XML documentation files, matching C#; `` is not implemented. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188))
diff --git a/eng/Build.ps1 b/eng/Build.ps1
index 01ff6313626..feba12579c1 100644
--- a/eng/Build.ps1
+++ b/eng/Build.ps1
@@ -66,6 +66,7 @@ param (
[switch]$testpack,
[switch]$testAOT,
[switch]$testEditor,
+ [switch]$testHotReload,
[string]$officialSkipTests = "false",
[switch]$noVisualStudio,
[switch][Alias('pb')]$productBuild,
@@ -131,6 +132,7 @@ function Print-Usage() {
Write-Host " -testpack Verify built packages"
Write-Host " -testAOT Run AOT/Trimming tests"
Write-Host " -testEditor Run VS Editor tests"
+ Write-Host " -testHotReload Run the hot reload demo smoke test"
Write-Host " -officialSkipTests Set to 'true' to skip running tests"
Write-Host ""
Write-Host "Advanced settings:"
@@ -153,6 +155,52 @@ function Print-Usage() {
Write-Host "Command line arguments starting with '/p:' are passed through to MSBuild."
}
+function Invoke-HotReloadDemoSmokeTest([string] $dotnetExe) {
+ if ($script:HotReloadDemoSmokeTestExecuted) {
+ return
+ }
+
+ $demoDirectory = Join-Path $RepoRoot "tests/projects/HotReloadDemo/HotReloadDemoApp"
+ if (-not (Test-Path $demoDirectory)) {
+ Write-Verbose "Hot reload demo directory not found; skipping smoke test."
+ $script:HotReloadDemoSmokeTestExecuted = $True
+ return
+ }
+
+ Write-Host "Running hot reload demo smoke test..."
+
+ $previousValue = [System.Environment]::GetEnvironmentVariable("DOTNET_MODIFIABLE_ASSEMBLIES", "Process")
+ $output = @()
+ $exitCode = 0
+ try {
+ [System.Environment]::SetEnvironmentVariable("DOTNET_MODIFIABLE_ASSEMBLIES", "debug", "Process")
+
+ Push-Location $demoDirectory
+ try {
+ $output = & $dotnetExe run -- --scripted 2>&1
+ $exitCode = $LASTEXITCODE
+ }
+ finally {
+ Pop-Location
+ }
+ }
+ finally {
+ [System.Environment]::SetEnvironmentVariable("DOTNET_MODIFIABLE_ASSEMBLIES", $previousValue, "Process")
+ }
+
+ $output | ForEach-Object { Write-Host $_ }
+
+ if ($exitCode -ne 0) {
+ throw "Hot reload demo smoke test failed with exit code $exitCode"
+ }
+
+ if ($output -notmatch "Scripted run succeeded: delta emitted") {
+ throw "Hot reload demo smoke test did not report success marker"
+ }
+
+ $script:HotReloadDemoSmokeTestExecuted = $True
+}
+
# Process the command line arguments and establish defaults for the values which are not
# specified.
function Process-Arguments() {
@@ -166,6 +214,7 @@ function Process-Arguments() {
}
$script:nodeReuse = $False;
+ $script:HotReloadDemoSmokeTestExecuted = $False
if ($testAll) {
$script:testDesktop = $True
@@ -554,6 +603,11 @@ try {
$dotnetPath = InitializeDotNetCli
$env:DOTNET_ROOT = "$dotnetPath"
+ $dotnetExecutableName = "dotnet"
+ if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) {
+ $dotnetExecutableName = "dotnet.exe"
+ }
+ $dotnetExe = Join-Path $dotnetPath $dotnetExecutableName
Get-Item -Path Env:
if ($bootstrap) {
@@ -623,6 +677,10 @@ try {
}
}
+ if ($testHotReload) {
+ Invoke-HotReloadDemoSmokeTest $dotnetExe
+ }
+
if ($testFSharpCore) {
TestUsingMSBuild -testProject "$RepoRoot\tests\FSharp.Core.UnitTests\FSharp.Core.UnitTests.fsproj" -targetFramework $script:coreclrTargetFramework
TestUsingMSBuild -testProject "$RepoRoot\tests\FSharp.Core.UnitTests\FSharp.Core.UnitTests.fsproj" -targetFramework $script:desktopTargetFramework
diff --git a/src/Compiler/AbstractIL/DeltaIndexSizing.fs b/src/Compiler/AbstractIL/DeltaIndexSizing.fs
index 4ca3e280d4b..2e2c979461c 100644
--- a/src/Compiler/AbstractIL/DeltaIndexSizing.fs
+++ b/src/Compiler/AbstractIL/DeltaIndexSizing.fs
@@ -11,9 +11,10 @@ module internal FSharp.Compiler.AbstractIL.DeltaIndexSizing
open FSharp.Compiler.AbstractIL.BinaryConstants
open FSharp.Compiler.AbstractIL.ILDeltaHandles
-open FSharp.Compiler.AbstractIL.ILMetadataHeaps
open FSharp.Compiler.AbstractIL.DeltaMetadataEncoding
+type MetadataHeapSizes = FSharp.Compiler.AbstractIL.ILBinaryWriter.MetadataHeapSizes
+
/// Holds computed "bigness" flags for all coded index types.
/// When true, the index requires 4 bytes; when false, 2 bytes suffice.
type CodedIndexSizes =
diff --git a/src/Compiler/AbstractIL/DeltaMetadataSerializer.fs b/src/Compiler/AbstractIL/DeltaMetadataSerializer.fs
index 7033f74f8a8..c7ad17b5af7 100644
--- a/src/Compiler/AbstractIL/DeltaMetadataSerializer.fs
+++ b/src/Compiler/AbstractIL/DeltaMetadataSerializer.fs
@@ -4,7 +4,7 @@ open System
open System.Collections.Generic
open System.IO
open System.Text
-open FSharp.Compiler.AbstractIL.ILMetadataHeaps
+open FSharp.Compiler.AbstractIL.ILBinaryWriter
open FSharp.Compiler.AbstractIL.BinaryConstants
open FSharp.Compiler.AbstractIL.ILDeltaHandles
open FSharp.Compiler.AbstractIL.DeltaMetadataTables
diff --git a/src/Compiler/AbstractIL/DeltaMetadataTypes.fs b/src/Compiler/AbstractIL/DeltaMetadataTypes.fs
index 057ca154798..fb1f3f796d6 100644
--- a/src/Compiler/AbstractIL/DeltaMetadataTypes.fs
+++ b/src/Compiler/AbstractIL/DeltaMetadataTypes.fs
@@ -6,18 +6,8 @@ open FSharp.Compiler.AbstractIL.IL
open FSharp.Compiler.AbstractIL.BinaryConstants
open FSharp.Compiler.AbstractIL.ILDeltaHandles
-// ============================================================================
-// Definition keys
-// ============================================================================
-// Stable, content-based identifiers for metadata definitions. These are used to
-// correlate a definition across compiles/generations (e.g. baseline vs. fresh
-// compile) independently of row-id churn. Lifted from the hot-reload baseline
-// module: unlike the rest of that module (FSharpEmitBaseline, handle caches,
-// token maps, TypeReferenceKey, ...), these records carry no session state and
-// are pure structural identities over ILType/string data, so they belong beside
-// the *RowInfo contract types below rather than with baseline bookkeeping.
-
-/// Stable identifier for a method definition used when correlating baseline tokens.
+// Stable structural definition keys are shared by baseline capture and the writer.
+// Keeping them here prevents the metadata layer from depending on session state.
type MethodDefinitionKey =
{
DeclaringType: string
@@ -27,14 +17,12 @@ type MethodDefinitionKey =
ReturnType: ILType
}
-/// Stable identifier for a method parameter (sequence number within a method).
type ParameterDefinitionKey =
{
Method: MethodDefinitionKey
SequenceNumber: int
}
-/// Stable identifier for a field definition in the baseline assembly.
type FieldDefinitionKey =
{
DeclaringType: string
@@ -42,7 +30,6 @@ type FieldDefinitionKey =
FieldType: ILType
}
-/// Stable identifier for a property definition (including indexer parameter shapes).
type PropertyDefinitionKey =
{
DeclaringType: string
@@ -51,7 +38,6 @@ type PropertyDefinitionKey =
IndexParameterTypes: ILType list
}
-/// Stable identifier for an event definition in the baseline assembly.
type EventDefinitionKey =
{
DeclaringType: string
@@ -59,8 +45,6 @@ type EventDefinitionKey =
EventType: ILType option
}
-/// Identifies the property or event a MethodSemantics row (getter/setter/add/remove) is
-/// associated with, plus the row id of that PropertyMap/EventMap-owned parent.
type MethodSemanticsAssociation =
| PropertyAssociation of PropertyDefinitionKey * rowId: int
| EventAssociation of EventDefinitionKey * rowId: int
diff --git a/src/Compiler/AbstractIL/EncMethodDebugInformation.fs b/src/Compiler/AbstractIL/EncMethodDebugInformation.fs
index e605b2208a4..36160e5c981 100644
--- a/src/Compiler/AbstractIL/EncMethodDebugInformation.fs
+++ b/src/Compiler/AbstractIL/EncMethodDebugInformation.fs
@@ -31,8 +31,11 @@ open System.IO
open System.Reflection.Metadata
open System.Reflection.Metadata.Ecma335
open System.Runtime.InteropServices
+open System.Text
open Microsoft.FSharp.NativeInterop
+open FSharp.Compiler.AbstractIL.ILPdbWriter
+
/// Portable-PDB CustomDebugInformation kind GUIDs for the EnC blobs, copied verbatim
/// from roslyn/src/Dependencies/CodeAnalysis.Debugging/PortableCustomDebugInfoKinds.cs.
[]
@@ -47,6 +50,10 @@ module PortableCustomDebugInfoKinds =
/// EnC State Machine State Map CDI kind.
let encStateMachineStateMap = Guid("8B78CD68-2EDE-420B-980B-E15884B8AAA3")
+ /// F#-owned hot reload synthesized-name snapshot CDI kind. The blob records
+ /// FSharpSynthesizedTypeMaps.Snapshot bucket arrays in allocation-slot order.
+ let fsharpSynthesizedNameSnapshot = Guid("49DDB47E-9C74-46EC-8626-0350676571EB")
+
/// Closure ordinal of a lambda that is lowered to a static (non-capturing) method.
/// Mirrors Roslyn's LambdaDebugInfo.StaticClosureOrdinal.
[]
@@ -167,7 +174,7 @@ let private MaxOccurrenceKey = 0x1FFFFFFD
/// 16-bit segments, least-significant segment = the innermost ordinal; an enclosing
/// ordinal p is stored as (p + 1) shifted left 16 so that depth-1 keys (< 0x10000) and
/// depth-2 keys (>= 0x10000) never collide. Fails closed (None) past the limits: chains
-/// deeper than 2, ordinals > 0xFFFF, or keys exceeding the compressed-integer budget —
+/// deeper than 2, ordinals > 0xFFFF, or keys exceeding the compressed-integer budget,
/// callers must then treat the chain as unmappable, never truncate.
let tryEncodeOccurrenceKey (ordinalChain: int list) : int option =
match ordinalChain with
@@ -209,6 +216,134 @@ let private invalidData (blobName: string) (offset: int) =
// nullness model, so guard with box (FS3261-safe) rather than dropping the check.
let private isEmpty (blob: byte[]) = isNull (box blob) || blob.Length = 0
+// ---------------------------------------------------------------------------
+// F# hot reload module CDI: synthesized-name allocation snapshot
+// Format:
+// compressed(version = 1), compressed(bucket count),
+// then buckets sorted by key for deterministic PDB bytes:
+// string key, compressed(name count), string name in allocation-slot order.
+// Strings are compressed(byte length) followed by UTF-8 bytes.
+// ---------------------------------------------------------------------------
+
+[]
+let private SynthesizedNameSnapshotBlobVersion = 1
+
+let private writeUtf8String (builder: BlobBuilder) (value: string) =
+ if isNull (box value) then
+ invalidArg (nameof value) "snapshot strings must be non-null"
+
+ let bytes = Encoding.UTF8.GetBytes value
+ builder.WriteCompressedInteger bytes.Length
+ builder.WriteBytes bytes
+
+let private readUtf8String (blobName: string) (reader: byref) =
+ let length = reader.ReadCompressedInteger()
+
+ if length < 0 || length > reader.RemainingBytes then
+ invalidData blobName reader.Offset
+
+ let bytes = reader.ReadBytes length
+ Encoding.UTF8.GetString(bytes, 0, bytes.Length)
+
+let private materializeSynthesizedNameSnapshot (snapshot: seq) =
+ snapshot
+ |> Seq.map (fun struct (key, names) ->
+ if isNull (box key) then
+ invalidArg (nameof snapshot) "snapshot keys must be non-null"
+
+ if isNull (box names) then
+ invalidArg (nameof snapshot) $"snapshot bucket '{key}' must be non-null"
+
+ key, Array.copy names)
+ |> Seq.sortBy fst
+ |> Seq.toArray
+
+/// Serializes an allocation-ordered synthesized-name snapshot into the F#-owned module
+/// CDI blob. An empty snapshot returns an empty blob so no CDI row needs to be emitted.
+let serializeSynthesizedNameSnapshot (snapshot: seq) : byte[] =
+ let buckets = materializeSynthesizedNameSnapshot snapshot
+
+ if buckets.Length = 0 then
+ Array.empty
+ else
+ let builder = BlobBuilder()
+ builder.WriteCompressedInteger SynthesizedNameSnapshotBlobVersion
+ builder.WriteCompressedInteger buckets.Length
+
+ for key, names in buckets do
+ writeUtf8String builder key
+ builder.WriteCompressedInteger names.Length
+
+ for name in names do
+ writeUtf8String builder name
+
+ builder.ToArray()
+
+/// Deserializes the F#-owned synthesized-name snapshot CDI blob. Bucket order in the
+/// blob is deterministic only; each bucket array is returned exactly in recorded slot order.
+let deserializeSynthesizedNameSnapshot (blob: byte[]) : Map =
+ if isEmpty blob then
+ Map.empty
+ else
+ let handle = GCHandle.Alloc(blob, GCHandleType.Pinned)
+
+ try
+ let mutable reader =
+ BlobReader(NativePtr.ofNativeInt (handle.AddrOfPinnedObject()), blob.Length)
+
+ try
+ let version = reader.ReadCompressedInteger()
+
+ if version <> SynthesizedNameSnapshotBlobVersion then
+ invalidData "synthesized name snapshot" reader.Offset
+
+ let bucketCount = reader.ReadCompressedInteger()
+
+ if bucketCount <= 0 || bucketCount > reader.RemainingBytes / 2 then
+ invalidData "synthesized name snapshot" reader.Offset
+
+ let buckets = ResizeArray()
+
+ for _ in 1..bucketCount do
+ let key = readUtf8String "synthesized name snapshot" &reader
+ let nameCount = reader.ReadCompressedInteger()
+
+ // Every serialized name consumes at least one byte for its UTF-8
+ // length, so this check bounds allocation before Array.zeroCreate.
+ if nameCount < 0 || nameCount > reader.RemainingBytes then
+ invalidData "synthesized name snapshot" reader.Offset
+
+ let names = Array.zeroCreate nameCount
+
+ for i in 0 .. nameCount - 1 do
+ names[i] <- readUtf8String "synthesized name snapshot" &reader
+
+ buckets.Add(key, names)
+
+ if reader.RemainingBytes <> 0 then
+ invalidData "synthesized name snapshot" reader.Offset
+
+ buckets |> Seq.map id |> Map.ofSeq
+ with :? BadImageFormatException ->
+ invalidData "synthesized name snapshot" reader.Offset
+ finally
+ handle.Free()
+
+/// Creates the module-level CustomDebugInformation row for the allocation-ordered
+/// synthesized-name snapshot. Empty snapshots emit no row.
+let computeSynthesizedNameSnapshotCustomDebugInfoRows (snapshot: seq) : PdbModuleCustomDebugInfo list =
+ let blob = serializeSynthesizedNameSnapshot snapshot
+
+ if blob.Length = 0 then
+ []
+ else
+ [
+ {
+ KindGuid = PortableCustomDebugInfoKinds.fsharpSynthesizedNameSnapshot
+ Blob = blob
+ }
+ ]
+
// ---------------------------------------------------------------------------
// EnC Local Slot Map
// Format (EditAndContinueMethodDebugInformation.cs, SerializeLocalSlots lines 145-191,
@@ -555,3 +690,35 @@ let readEncMethodDebugInfoFromPortablePdb (pdbBytes: byte[]) : Map option =
+ if isEmpty pdbBytes then
+ None
+ else
+ try
+ use provider =
+ MetadataReaderProvider.FromPortablePdbImage(ImmutableArray.CreateRange pdbBytes)
+
+ let reader = provider.GetMetadataReader()
+
+ let blobs =
+ [
+ for cdiHandle in reader.CustomDebugInformation do
+ let cdi = reader.GetCustomDebugInformation cdiHandle
+
+ if cdi.Parent.Kind = HandleKind.ModuleDefinition then
+ let kind = reader.GetGuid cdi.Kind
+
+ if kind = PortableCustomDebugInfoKinds.fsharpSynthesizedNameSnapshot then
+ reader.GetBlobBytes cdi.Value
+ ]
+
+ match blobs with
+ | [ blob ] -> Some(deserializeSynthesizedNameSnapshot blob)
+ | _ -> None
+ with
+ | :? BadImageFormatException
+ | :? InvalidDataException -> None
diff --git a/src/Compiler/AbstractIL/EncMethodDebugInformation.fsi b/src/Compiler/AbstractIL/EncMethodDebugInformation.fsi
index 1e2ba76e7c8..6d83c3a42b6 100644
--- a/src/Compiler/AbstractIL/EncMethodDebugInformation.fsi
+++ b/src/Compiler/AbstractIL/EncMethodDebugInformation.fsi
@@ -36,6 +36,9 @@ module PortableCustomDebugInfoKinds =
/// EnC State Machine State Map CDI kind.
val encStateMachineStateMap: System.Guid
+ /// F#-owned hot reload synthesized-name snapshot CDI kind.
+ val fsharpSynthesizedNameSnapshot: System.Guid
+
/// Closure ordinal of a lambda that is lowered to a static (non-capturing) method.
/// Mirrors Roslyn's LambdaDebugInfo.StaticClosureOrdinal.
[]
@@ -135,6 +138,19 @@ val tryEncodeOccurrenceKey: ordinalChain: int list -> int option
/// root-first ordinal chain.
val decodeOccurrenceKey: key: int -> int list
+/// Serializes an allocation-ordered synthesized-name snapshot into the F#-owned module
+/// CDI blob. An empty snapshot returns an empty blob so no CDI row needs to be emitted.
+val serializeSynthesizedNameSnapshot: snapshot: seq -> byte[]
+
+/// Deserializes the F#-owned synthesized-name snapshot CDI blob. Bucket order in the
+/// blob is deterministic only; each bucket array is returned exactly in recorded slot order.
+val deserializeSynthesizedNameSnapshot: blob: byte[] -> Map
+
+/// Creates the module-level CustomDebugInformation row for the allocation-ordered
+/// synthesized-name snapshot. Empty snapshots emit no row.
+val computeSynthesizedNameSnapshotCustomDebugInfoRows:
+ snapshot: seq -> FSharp.Compiler.AbstractIL.ILPdbWriter.PdbModuleCustomDebugInfo list
+
/// Serializes the EnC Local Slot Map blob for 'info', byte-for-byte as Roslyn's
/// SerializeLocalSlots. Returns the empty array when there are no slots (no CDI row
/// should be emitted then).
@@ -176,3 +192,8 @@ val deserialize:
/// Fail safe: a null/empty or non-PDB image yields the empty map, and a method whose
/// blobs do not decode is omitted rather than guessed.
val readEncMethodDebugInfoFromPortablePdb: pdbBytes: byte[] -> Map
+
+/// Reads the F#-owned allocation-ordered synthesized-name snapshot from a portable PDB.
+/// None means either the record is absent or invalid; callers must fall back to IL
+/// reconstruction rather than trusting a partial layout.
+val readSynthesizedNameSnapshotFromPortablePdb: pdbBytes: byte[] -> Map option
diff --git a/src/Compiler/AbstractIL/FSharpDeltaMetadataWriter.fs b/src/Compiler/AbstractIL/FSharpDeltaMetadataWriter.fs
index 85ba1c7e823..0bc6070eacc 100644
--- a/src/Compiler/AbstractIL/FSharpDeltaMetadataWriter.fs
+++ b/src/Compiler/AbstractIL/FSharpDeltaMetadataWriter.fs
@@ -2,8 +2,9 @@ module internal FSharp.Compiler.AbstractIL.FSharpDeltaMetadataWriter
open System
open System.Collections.Generic
+open FSharp.Compiler.EnvironmentHelpers
open Microsoft.FSharp.Collections
-open FSharp.Compiler.AbstractIL.ILMetadataHeaps
+open FSharp.Compiler.AbstractIL.ILBinaryWriter
open FSharp.Compiler.AbstractIL.BinaryConstants
open FSharp.Compiler.AbstractIL.ILDeltaHandles
open FSharp.Compiler.AbstractIL.IlxDeltaStreams
@@ -21,18 +22,6 @@ let private TraceHeapsFlagName = "FSHARP_HOTRELOAD_TRACE_HEAPS"
[]
let private TraceMethodsFlagName = "FSHARP_HOTRELOAD_TRACE_METHODS"
-/// Local copy of FSharp.Compiler.EnvironmentHelpers.isEnvVarTruthy. That module is a new
-/// utility file added by the hot-reload feature branch and isn't part of this extraction's
-/// scope, so the writer's trace-flag checks carry their own tiny copy instead of pulling in
-/// an extra out-of-scope file.
-let private isEnvVarTruthy (name: string) =
- match Environment.GetEnvironmentVariable(name) with
- | null
- | "" -> false
- | value when String.Equals(value, "1", StringComparison.OrdinalIgnoreCase) -> true
- | value when String.Equals(value, "true", StringComparison.OrdinalIgnoreCase) -> true
- | _ -> false
-
let private shouldTraceMetadata () = isEnvVarTruthy TraceMetadataFlagName
let private shouldTraceHeaps () = isEnvVarTruthy TraceHeapsFlagName
diff --git a/src/Compiler/AbstractIL/ILBaselineReader.fs b/src/Compiler/AbstractIL/ILBaselineReader.fs
new file mode 100644
index 00000000000..f441c55bfd3
--- /dev/null
+++ b/src/Compiler/AbstractIL/ILBaselineReader.fs
@@ -0,0 +1,1419 @@
+/// Minimal binary reader for baseline metadata extraction.
+/// Replaces SRM MetadataReader dependency for hot reload baseline creation.
+/// Parses PE/CLI metadata headers to extract heap sizes and table row counts.
+///
+/// This module provides a pure F# implementation for reading the minimum metadata
+/// needed to create an FSharpEmitBaseline, without requiring System.Reflection.Metadata.
+///
+/// References:
+/// - ECMA-335 II.24 (Metadata physical layout)
+/// - Roslyn DeltaMetadataWriter.cs for heap offset handling
+module internal FSharp.Compiler.AbstractIL.ILBaselineReader
+
+open System
+open System.Collections.Immutable
+open System.IO
+open System.Reflection.PortableExecutable
+open FSharp.Compiler.AbstractIL.ILBinaryWriter
+
+/// Read a little-endian 16-bit integer from bytes at offset.
+let private readUInt16 (bytes: byte[]) (offset: int) =
+ uint16 bytes.[offset] ||| (uint16 bytes.[offset + 1] <<< 8)
+
+/// Read a little-endian 32-bit integer from bytes at offset.
+let private readInt32 (bytes: byte[]) (offset: int) =
+ int bytes.[offset]
+ ||| (int bytes.[offset + 1] <<< 8)
+ ||| (int bytes.[offset + 2] <<< 16)
+ ||| (int bytes.[offset + 3] <<< 24)
+
+/// Reads an unsigned 64-bit little-endian value without sign-extending either half.
+let internal readUInt64 (bytes: byte[]) (offset: int) =
+ uint64 (uint32 (readInt32 bytes offset))
+ ||| (uint64 (uint32 (readInt32 bytes (offset + 4))) <<< 32)
+
+/// Number of metadata tables per ECMA-335.
+let private tableCount = 64
+
+/// Find the CLI metadata root in PE file bytes.
+/// Returns the offset to the metadata root, or None if not found.
+let private findMetadataRoot (bytes: byte[]) : int option =
+ // Check DOS header magic
+ if bytes.Length < 64 || bytes.[0] <> 0x4Duy || bytes.[1] <> 0x5Auy then
+ None
+ else
+ // e_lfanew at offset 0x3C points to PE signature
+ let peOffset = readInt32 bytes 0x3C
+
+ if peOffset < 0 || peOffset + 24 > bytes.Length then
+ None
+ else if
+ // Check PE signature "PE\0\0"
+ bytes.[peOffset] <> 0x50uy
+ || bytes.[peOffset + 1] <> 0x45uy
+ || bytes.[peOffset + 2] <> 0uy
+ || bytes.[peOffset + 3] <> 0uy
+ then
+ None
+ else
+ // COFF header at peOffset + 4
+ let coffHeader = peOffset + 4
+ let sizeOfOptionalHeader = int (readUInt16 bytes (coffHeader + 16))
+ let optionalHeader = coffHeader + 20
+
+ // PE32 vs PE32+ - check magic
+ let magic = readUInt16 bytes optionalHeader
+ let isPE32Plus = magic = 0x20Bus
+
+ // CLI header RVA is in data directory entry 14 (0-indexed)
+ // PE32: starts at optionalHeader + 96; PE32+: starts at optionalHeader + 112
+ let dataDirectoryStart =
+ if isPE32Plus then
+ optionalHeader + 112
+ else
+ optionalHeader + 96
+
+ let cliHeaderRVA = readInt32 bytes (dataDirectoryStart + 14 * 8)
+
+ if cliHeaderRVA = 0 then
+ None
+ else
+ // Convert RVA to file offset using section headers
+ let numberOfSections = int (readUInt16 bytes (coffHeader + 2))
+ let sectionHeadersStart = optionalHeader + sizeOfOptionalHeader
+
+ let rec findSection sectionIndex =
+ if sectionIndex >= numberOfSections then
+ None
+ else
+ let sectionOffset = sectionHeadersStart + sectionIndex * 40
+ let virtualAddress = readInt32 bytes (sectionOffset + 12)
+ let virtualSize = readInt32 bytes (sectionOffset + 8)
+ let pointerToRawData = readInt32 bytes (sectionOffset + 20)
+
+ if cliHeaderRVA >= virtualAddress && cliHeaderRVA < virtualAddress + virtualSize then
+ let cliHeaderOffset = cliHeaderRVA - virtualAddress + pointerToRawData
+ // CLI header contains MetaData RVA at offset 8
+ let metadataRVA = readInt32 bytes (cliHeaderOffset + 8)
+ // Convert metadata RVA to file offset
+ Some(metadataRVA - virtualAddress + pointerToRawData)
+ else
+ findSection (sectionIndex + 1)
+
+ findSection 0
+
+/// Stream header information.
+type private StreamHeader =
+ { Offset: int; Size: int; Name: string }
+
+/// Parse stream headers from metadata root.
+let private parseStreamHeaders (bytes: byte[]) (metadataRoot: int) : StreamHeader list =
+ // Metadata root signature at offset 0
+ let signature = readInt32 bytes metadataRoot
+
+ if signature <> 0x424A5342 then // "BSJB"
+ []
+ else
+ // Version string length at offset 12
+ let versionLength = readInt32 bytes (metadataRoot + 12)
+ let paddedVersionLength = (versionLength + 3) &&& ~~~3
+
+ // Number of streams at offset 16 + paddedVersionLength + 2
+ let streamsOffset = metadataRoot + 16 + paddedVersionLength
+ let numberOfStreams = int (readUInt16 bytes (streamsOffset + 2))
+
+ // Stream headers start at streamsOffset + 4
+ let mutable currentOffset = streamsOffset + 4
+ let headers = ResizeArray()
+
+ for _ in 1..numberOfStreams do
+ let offset = readInt32 bytes currentOffset
+ let size = readInt32 bytes (currentOffset + 4)
+
+ // Read null-terminated stream name (padded to 4-byte boundary)
+ let mutable nameEnd = currentOffset + 8
+
+ while bytes.[nameEnd] <> 0uy do
+ nameEnd <- nameEnd + 1
+
+ let name =
+ System.Text.Encoding.ASCII.GetString(bytes, currentOffset + 8, nameEnd - currentOffset - 8)
+
+ let paddedNameLength = ((nameEnd - currentOffset - 8 + 1) + 3) &&& ~~~3
+
+ headers.Add(
+ {
+ Offset = metadataRoot + offset
+ Size = size
+ Name = name
+ }
+ )
+
+ currentOffset <- currentOffset + 8 + paddedNameLength
+
+ headers |> Seq.toList
+
+/// Find a stream by name.
+let private findStream (headers: StreamHeader list) (name: string) : StreamHeader option =
+ headers |> List.tryFind (fun h -> h.Name = name)
+
+/// Parse table row counts from the #~ or #- stream.
+/// Returns (heapSizes byte, table row counts array, tables stream offset, valid-table mask).
+let private parseTablesStream (bytes: byte[]) (tablesStream: StreamHeader) : byte * int[] * int * uint64 =
+ let offset = tablesStream.Offset
+
+ // Header structure:
+ // 0-3: Reserved (0)
+ // 4: MajorVersion
+ // 5: MinorVersion
+ // 6: HeapSizes byte
+ // 7: Reserved
+ // 8-15: Valid (bitmask of present tables)
+ // 16-23: Sorted (bitmask of sorted tables)
+ // 24+: Row counts for present tables
+
+ let heapSizes = bytes.[offset + 6]
+ let valid = readUInt64 bytes (offset + 8)
+
+ let rowCounts = Array.zeroCreate tableCount
+ let mutable rowCountOffset = offset + 24
+
+ for i in 0..63 do
+ if (valid &&& (1UL <<< i)) <> 0UL then
+ let rowCount = readInt32 bytes rowCountOffset
+
+ if rowCount < 0 then
+ invalidArg (nameof bytes) "metadata table row counts must be non-negative"
+
+ rowCounts.[i] <- rowCount
+ rowCountOffset <- rowCountOffset + 4
+
+ heapSizes, rowCounts, offset, valid
+
+/// Computes the first table-row offset from the table header's valid-table mask.
+let internal tableDataStart tablesOffset (valid: uint64) =
+ let mutable remaining = valid
+ let mutable presentTableCount = 0
+
+ while remaining <> 0UL do
+ presentTableCount <- presentTableCount + 1
+ remaining <- remaining &&& (remaining - 1UL)
+
+ tablesOffset + 24 + (presentTableCount * 4)
+
+/// Extract metadata snapshot from PE file bytes.
+/// This replaces metadataSnapshotFromReader for hot reload baseline creation.
+let metadataSnapshotFromBytes (bytes: byte[]) : MetadataSnapshot option =
+ match findMetadataRoot bytes with
+ | None -> None
+ | Some metadataRoot ->
+ let streamHeaders = parseStreamHeaders bytes metadataRoot
+
+ // Find required streams
+ let stringsStream = findStream streamHeaders "#Strings"
+ let userStringsStream = findStream streamHeaders "#US"
+ let blobStream = findStream streamHeaders "#Blob"
+ let guidStream = findStream streamHeaders "#GUID"
+
+ let tablesStream =
+ findStream streamHeaders "#~" |> Option.orElse (findStream streamHeaders "#-")
+
+ match tablesStream with
+ | None -> None
+ | Some tables ->
+ let _, rowCounts, _, _ = parseTablesStream bytes tables
+
+ // SRM's StringHeap trims the #Strings alignment padding down to a single
+ // terminating zero (StringHeap.TrimEnd), and EnC heap aggregation (runtime,
+ // MetadataAggregator, Roslyn EmitBaseline) places generation-1 strings right
+ // after that TRIMMED size. The baseline snapshot must use the same virtual size
+ // or every delta-heap string reference is shifted by the padding bytes.
+ // #US/#Blob/#GUID are not trimmed by SRM and keep the stream header size.
+ let trimmedStringHeapSize =
+ match stringsStream with
+ | None -> 0
+ | Some stream ->
+ if stream.Size = 0 then
+ 0
+ else
+ let last = stream.Offset + stream.Size - 1
+ let mutable i = last
+
+ while i >= stream.Offset && bytes.[i] = 0uy do
+ i <- i - 1
+
+ if i = last then
+ // No trailing zero: malformed but mirror SRM and keep the raw size.
+ stream.Size
+ else
+ // Keep one terminating zero after the last non-zero byte.
+ i - stream.Offset + 2
+
+ let heapSizeInfo =
+ {
+ StringHeapSize = trimmedStringHeapSize
+ UserStringHeapSize = userStringsStream |> Option.map (fun s -> s.Size) |> Option.defaultValue 0
+ BlobHeapSize = blobStream |> Option.map (fun s -> s.Size) |> Option.defaultValue 0
+ GuidHeapSize = guidStream |> Option.map (fun s -> s.Size) |> Option.defaultValue 0
+ }
+
+ Some
+ {
+ HeapSizes = heapSizeInfo
+ TableRowCounts = rowCounts
+ GuidHeapStart = heapSizeInfo.GuidHeapSize
+ }
+
+/// Read GUID from #GUID stream at 1-based index.
+let readGuidFromBytes (bytes: byte[]) (guidIndex: int) : Guid option =
+ if guidIndex <= 0 then
+ None
+ else
+ match findMetadataRoot bytes with
+ | None -> None
+ | Some metadataRoot ->
+ let streamHeaders = parseStreamHeaders bytes metadataRoot
+
+ match findStream streamHeaders "#GUID" with
+ | None -> None
+ | Some guidStream ->
+ // GUID indices are 1-based; each GUID is 16 bytes
+ let offset = guidStream.Offset + (guidIndex - 1) * 16
+ let streamEnd = int64 guidStream.Offset + int64 guidStream.Size
+ let guidEnd = int64 offset + 16L
+
+ if
+ guidStream.Offset < 0
+ || guidStream.Size < 0
+ || streamEnd > int64 bytes.Length
+ || offset < guidStream.Offset
+ || guidEnd > streamEnd
+ then
+ None
+ else
+ let guidBytes = bytes.[offset .. offset + 15]
+ Some(System.Guid(guidBytes))
+
+/// Reads the portable CodeView content ID embedded in a PE debug directory.
+let readCodeViewContentIdFromBytes (bytes: byte[]) : byte[] option =
+ try
+ use peReader = new PEReader(ImmutableArray.CreateRange bytes)
+
+ peReader.ReadDebugDirectory()
+ |> Seq.tryFind (fun entry -> entry.IsPortableCodeView)
+ |> Option.map (fun entry ->
+ let data = peReader.ReadCodeViewDebugDirectoryData entry
+ let contentId = Array.zeroCreate 20
+ data.Guid.ToByteArray().CopyTo(contentId, 0)
+ BitConverter.GetBytes(entry.Stamp).CopyTo(contentId, 16)
+ contentId)
+ with
+ | :? BadImageFormatException
+ | :? IOException
+ | :? InvalidOperationException -> None
+
+// ============================================================================
+// Table row reading infrastructure
+// ============================================================================
+
+/// Table indices per ECMA-335 II.22
+module private TableIndices =
+ let Module = 0
+ let TypeRef = 1
+ let TypeDef = 2
+ let FieldPtr = 3
+ let Field = 4
+ let MethodPtr = 5
+ let MethodDef = 6
+ let ParamPtr = 7
+ let Param = 8
+ let InterfaceImpl = 9
+ let MemberRef = 10
+ let Constant = 11
+ let CustomAttribute = 12
+ let FieldMarshal = 13
+ let DeclSecurity = 14
+ let ClassLayout = 15
+ let FieldLayout = 16
+ let StandAloneSig = 17
+ let EventMap = 18
+ let EventPtr = 19
+ let Event = 20
+ let PropertyMap = 21
+ let PropertyPtr = 22
+ let Property = 23
+ let MethodSemantics = 24
+ let MethodImpl = 25
+ let ModuleRef = 26
+ let TypeSpec = 27
+ let ImplMap = 28
+ let FieldRVA = 29
+ let Assembly = 32
+ let AssemblyRef = 35
+ let File = 38
+ let ExportedType = 39
+ let ManifestResource = 40
+ let NestedClass = 41
+ let GenericParam = 42
+ let MethodSpec = 43
+ let GenericParamConstraint = 44
+
+/// Parsed metadata context for reading table rows.
+/// Internal (not private): tiny reader members like TypeRefCount get cross-module
+/// inlined in Release builds, and inlined code referencing a module-private type
+/// fails CLR visibility checks at runtime (observed as an access violation on
+/// RowCounts from HotReloadBaseline's state machines).
+type internal MetadataContext =
+ {
+ Bytes: byte[]
+ HeapSizes: byte
+ RowCounts: int[]
+ TablesStart: int
+ StringIndexSize: int
+ GuidIndexSize: int
+ BlobIndexSize: int
+ StringsStreamOffset: int
+ StringsStreamSize: int
+ BlobStreamOffset: int
+ }
+
+/// Calculate index size for a simple table reference (2 if <=65535 rows, else 4).
+let private tableIndexSize (rowCounts: int[]) (tableIndex: int) =
+ if rowCounts.[tableIndex] <= 65535 then 2 else 4
+
+/// Calculate index size for a coded index (multiple possible tables).
+/// The tag takes some bits, so max row must fit in remaining bits.
+let private codedIndexSize (rowCounts: int[]) (tableIndices: int[]) (tagBits: int) =
+ let maxRows =
+ tableIndices
+ |> Array.map (fun i -> if i < 64 then rowCounts.[i] else 0)
+ |> Array.max
+
+ let maxValue = (maxRows <<< tagBits) ||| ((1 <<< tagBits) - 1)
+ if maxValue <= 65535 then 2 else 4
+
+/// ResolutionScope coded index: Module(0), ModuleRef(1), AssemblyRef(2), TypeRef(3) - 2 tag bits
+let private resolutionScopeSize (rowCounts: int[]) =
+ codedIndexSize
+ rowCounts
+ [|
+ TableIndices.Module
+ TableIndices.ModuleRef
+ TableIndices.AssemblyRef
+ TableIndices.TypeRef
+ |]
+ 2
+
+/// TypeDefOrRef coded index: TypeDef(0), TypeRef(1), TypeSpec(2) - 2 tag bits
+let private typeDefOrRefSize (rowCounts: int[]) =
+ codedIndexSize rowCounts [| TableIndices.TypeDef; TableIndices.TypeRef; TableIndices.TypeSpec |] 2
+
+/// HasConstant coded index - 2 tag bits
+let private hasConstantSize (rowCounts: int[]) =
+ codedIndexSize rowCounts [| TableIndices.Field; TableIndices.Param; TableIndices.Property |] 2
+
+/// HasCustomAttribute coded index - 5 tag bits (22 possible tables, ECMA-335 II.24.2.6)
+let private hasCustomAttributeSize (rowCounts: int[]) =
+ let tables =
+ [|
+ TableIndices.MethodDef
+ TableIndices.Field
+ TableIndices.TypeRef
+ TableIndices.TypeDef
+ TableIndices.Param
+ TableIndices.InterfaceImpl
+ TableIndices.MemberRef
+ TableIndices.Module
+ TableIndices.DeclSecurity
+ TableIndices.Property
+ TableIndices.Event
+ TableIndices.StandAloneSig
+ TableIndices.ModuleRef
+ TableIndices.TypeSpec
+ TableIndices.Assembly
+ TableIndices.AssemblyRef
+ TableIndices.File
+ TableIndices.ExportedType
+ TableIndices.ManifestResource
+ TableIndices.GenericParam
+ TableIndices.GenericParamConstraint
+ TableIndices.MethodSpec
+ |]
+
+ codedIndexSize rowCounts tables 5
+
+/// HasFieldMarshal coded index - 1 tag bit
+let private hasFieldMarshalSize (rowCounts: int[]) =
+ codedIndexSize rowCounts [| TableIndices.Field; TableIndices.Param |] 1
+
+/// HasDeclSecurity coded index - 2 tag bits
+let private hasDeclSecuritySize (rowCounts: int[]) =
+ codedIndexSize rowCounts [| TableIndices.TypeDef; TableIndices.MethodDef; TableIndices.Assembly |] 2
+
+/// MemberRefParent coded index - 3 tag bits
+let private memberRefParentSize (rowCounts: int[]) =
+ codedIndexSize
+ rowCounts
+ [|
+ TableIndices.TypeDef
+ TableIndices.TypeRef
+ TableIndices.ModuleRef
+ TableIndices.MethodDef
+ TableIndices.TypeSpec
+ |]
+ 3
+
+/// HasSemantics coded index - 1 tag bit
+let private hasSemanticsSize (rowCounts: int[]) =
+ codedIndexSize rowCounts [| TableIndices.Event; TableIndices.Property |] 1
+
+/// MethodDefOrRef coded index - 1 tag bit
+let private methodDefOrRefSize (rowCounts: int[]) =
+ codedIndexSize rowCounts [| TableIndices.MethodDef; TableIndices.MemberRef |] 1
+
+/// MemberForwarded coded index - 1 tag bit
+let private memberForwardedSize (rowCounts: int[]) =
+ codedIndexSize rowCounts [| TableIndices.Field; TableIndices.MethodDef |] 1
+
+/// Implementation coded index - 2 tag bits
+let private implementationSize (rowCounts: int[]) =
+ codedIndexSize rowCounts [| TableIndices.File; TableIndices.AssemblyRef; TableIndices.ExportedType |] 2
+
+/// CustomAttributeType coded index - 3 tag bits
+let private customAttributeTypeSize (rowCounts: int[]) =
+ // Only MethodDef(2) and MemberRef(3) are used
+ codedIndexSize rowCounts [| 0; 0; TableIndices.MethodDef; TableIndices.MemberRef; 0 |] 3
+
+/// TypeOrMethodDef coded index - 1 tag bit
+let private typeOrMethodDefSize (rowCounts: int[]) =
+ codedIndexSize rowCounts [| TableIndices.TypeDef; TableIndices.MethodDef |] 1
+
+/// Calculate row size for each table per ECMA-335 II.22.
+let private calculateTableRowSizes (ctx: MetadataContext) : int[] =
+ let rc = ctx.RowCounts
+ let strIdx = ctx.StringIndexSize
+ let guidIdx = ctx.GuidIndexSize
+ let blobIdx = ctx.BlobIndexSize
+
+ let sizes = Array.zeroCreate tableCount
+
+ // Module: Generation(2) + Name(str) + Mvid(guid) + EncId(guid) + EncBaseId(guid)
+ sizes.[0] <- 2 + strIdx + guidIdx + guidIdx + guidIdx
+
+ // TypeRef: ResolutionScope(coded) + TypeName(str) + TypeNamespace(str)
+ sizes.[1] <- resolutionScopeSize rc + strIdx + strIdx
+
+ // TypeDef: Flags(4) + TypeName(str) + TypeNamespace(str) + Extends(TypeDefOrRef) + FieldList(Field) + MethodList(MethodDef)
+ sizes.[2] <-
+ 4
+ + strIdx
+ + strIdx
+ + typeDefOrRefSize rc
+ + tableIndexSize rc 4
+ + tableIndexSize rc 6
+
+ // Field: Flags(2) + Name(str) + Signature(blob)
+ sizes.[4] <- 2 + strIdx + blobIdx
+
+ // MethodDef: RVA(4) + ImplFlags(2) + Flags(2) + Name(str) + Signature(blob) + ParamList(Param)
+ sizes.[6] <- 4 + 2 + 2 + strIdx + blobIdx + tableIndexSize rc 8
+
+ // Param: Flags(2) + Sequence(2) + Name(str)
+ sizes.[8] <- 2 + 2 + strIdx
+
+ // InterfaceImpl: Class(TypeDef) + Interface(TypeDefOrRef)
+ // Missing this size silently shifted every later table's offset for assemblies with
+ // interface implementations (e.g. anonymous records implementing IEquatable).
+ sizes.[9] <- tableIndexSize rc 2 + typeDefOrRefSize rc
+
+ // MemberRef: Class(MemberRefParent) + Name(str) + Signature(blob)
+ sizes.[10] <- memberRefParentSize rc + strIdx + blobIdx
+
+ // Constant: Type(2) + Parent(HasConstant) + Value(blob)
+ sizes.[11] <- 2 + hasConstantSize rc + blobIdx
+
+ // CustomAttribute: Parent(HasCustomAttribute) + Type(CustomAttributeType) + Value(blob)
+ sizes.[12] <- hasCustomAttributeSize rc + customAttributeTypeSize rc + blobIdx
+
+ // FieldMarshal: Parent(HasFieldMarshal) + NativeType(blob)
+ sizes.[13] <- hasFieldMarshalSize rc + blobIdx
+
+ // DeclSecurity: Action(2) + Parent(HasDeclSecurity) + PermissionSet(blob)
+ sizes.[14] <- 2 + hasDeclSecuritySize rc + blobIdx
+
+ // ClassLayout: PackingSize(2) + ClassSize(4) + Parent(TypeDef)
+ sizes.[15] <- 2 + 4 + tableIndexSize rc 2
+
+ // FieldLayout: Offset(4) + Field(Field)
+ sizes.[16] <- 4 + tableIndexSize rc 4
+
+ // StandAloneSig: Signature(blob)
+ sizes.[17] <- blobIdx
+
+ // EventMap: Parent(TypeDef) + EventList(Event)
+ sizes.[18] <- tableIndexSize rc 2 + tableIndexSize rc 20
+
+ // Event: EventFlags(2) + Name(str) + EventType(TypeDefOrRef)
+ sizes.[20] <- 2 + strIdx + typeDefOrRefSize rc
+
+ // PropertyMap: Parent(TypeDef) + PropertyList(Property)
+ sizes.[21] <- tableIndexSize rc 2 + tableIndexSize rc 23
+
+ // Property: Flags(2) + Name(str) + Type(blob)
+ sizes.[23] <- 2 + strIdx + blobIdx
+
+ // MethodSemantics: Semantics(2) + Method(MethodDef) + Association(HasSemantics)
+ sizes.[24] <- 2 + tableIndexSize rc 6 + hasSemanticsSize rc
+
+ // MethodImpl: Class(TypeDef) + MethodBody(MethodDefOrRef) + MethodDeclaration(MethodDefOrRef)
+ sizes.[25] <- tableIndexSize rc 2 + methodDefOrRefSize rc + methodDefOrRefSize rc
+
+ // ModuleRef: Name(str)
+ sizes.[26] <- strIdx
+
+ // TypeSpec: Signature(blob)
+ sizes.[27] <- blobIdx
+
+ // ImplMap: MappingFlags(2) + MemberForwarded(MemberForwarded) + ImportName(str) + ImportScope(ModuleRef)
+ sizes.[28] <- 2 + memberForwardedSize rc + strIdx + tableIndexSize rc 26
+
+ // FieldRVA: RVA(4) + Field(Field)
+ sizes.[29] <- 4 + tableIndexSize rc 4
+
+ // Assembly: HashAlgId(4) + MajorVersion(2) + MinorVersion(2) + BuildNumber(2) + RevisionNumber(2) +
+ // Flags(4) + PublicKey(blob) + Name(str) + Culture(str)
+ sizes.[32] <- 4 + 2 + 2 + 2 + 2 + 4 + blobIdx + strIdx + strIdx
+
+ // AssemblyRef: MajorVersion(2) + MinorVersion(2) + BuildNumber(2) + RevisionNumber(2) +
+ // Flags(4) + PublicKeyOrToken(blob) + Name(str) + Culture(str) + HashValue(blob)
+ sizes.[35] <- 2 + 2 + 2 + 2 + 4 + blobIdx + strIdx + strIdx + blobIdx
+
+ // File: Flags(4) + Name(str) + HashValue(blob)
+ sizes.[38] <- 4 + strIdx + blobIdx
+
+ // ExportedType: Flags(4) + TypeDefId(4) + TypeName(str) + TypeNamespace(str) + Implementation(Implementation)
+ sizes.[39] <- 4 + 4 + strIdx + strIdx + implementationSize rc
+
+ // ManifestResource: Offset(4) + Flags(4) + Name(str) + Implementation(Implementation)
+ sizes.[40] <- 4 + 4 + strIdx + implementationSize rc
+
+ // NestedClass: NestedClass(TypeDef) + EnclosingClass(TypeDef)
+ sizes.[41] <- tableIndexSize rc 2 + tableIndexSize rc 2
+
+ // GenericParam: Number(2) + Flags(2) + Owner(TypeOrMethodDef) + Name(str)
+ sizes.[42] <- 2 + 2 + typeOrMethodDefSize rc + strIdx
+
+ // MethodSpec: Method(MethodDefOrRef) + Instantiation(blob)
+ sizes.[43] <- methodDefOrRefSize rc + blobIdx
+
+ // GenericParamConstraint: Owner(GenericParam) + Constraint(TypeDefOrRef)
+ sizes.[44] <- tableIndexSize rc 42 + typeDefOrRefSize rc
+
+ sizes
+
+/// Calculate the byte offset where each table starts within the tables stream.
+let private calculateTableOffsets (ctx: MetadataContext) (rowSizes: int[]) : int[] =
+ let offsets = Array.zeroCreate tableCount
+ let mutable currentOffset = ctx.TablesStart
+
+ for i in 0 .. tableCount - 1 do
+ offsets.[i] <- currentOffset
+ currentOffset <- currentOffset + rowSizes.[i] * ctx.RowCounts.[i]
+
+ offsets
+
+/// Read a heap index (2 or 4 bytes) from the given offset.
+let private readHeapIndex (bytes: byte[]) (offset: int) (indexSize: int) =
+ if indexSize = 2 then
+ int (readUInt16 bytes offset)
+ else
+ readInt32 bytes offset
+
+/// Create a metadata context for reading table rows.
+let private createMetadataContext (bytes: byte[]) : MetadataContext option =
+ match findMetadataRoot bytes with
+ | None -> None
+ | Some metadataRoot ->
+ let streamHeaders = parseStreamHeaders bytes metadataRoot
+
+ let tablesStreamOpt =
+ findStream streamHeaders "#~" |> Option.orElse (findStream streamHeaders "#-")
+
+ match tablesStreamOpt with
+ | None -> None
+ | Some tablesStream ->
+ let heapSizes, rowCounts, tablesOffset, valid = parseTablesStream bytes tablesStream
+
+ let pointerTables =
+ [|
+ TableIndices.FieldPtr
+ TableIndices.MethodPtr
+ TableIndices.ParamPtr
+ TableIndices.EventPtr
+ TableIndices.PropertyPtr
+ |]
+
+ // The #- stream permits pointer-table indirection. This reader consumes the
+ // definition tables directly, so accepting a non-empty pointer table would
+ // associate members with the wrong declaring type.
+ if
+ tablesStream.Name = "#-"
+ && pointerTables |> Array.exists (fun table -> rowCounts.[table] <> 0)
+ then
+ None
+ else
+ let stringsBig = (heapSizes &&& 0x01uy) <> 0uy
+ let guidsBig = (heapSizes &&& 0x02uy) <> 0uy
+ let blobsBig = (heapSizes &&& 0x04uy) <> 0uy
+
+ let tablesStart = tableDataStart tablesOffset valid
+
+ let stringsStream =
+ streamHeaders |> List.tryFind (fun header -> header.Name = "#Strings")
+
+ let blobOffset =
+ streamHeaders
+ |> List.tryFind (fun h -> h.Name = "#Blob")
+ |> Option.map (fun h -> h.Offset)
+ |> Option.defaultValue 0
+
+ Some
+ {
+ Bytes = bytes
+ HeapSizes = heapSizes
+ RowCounts = rowCounts
+ TablesStart = tablesStart
+ StringIndexSize = if stringsBig then 4 else 2
+ GuidIndexSize = if guidsBig then 4 else 2
+ BlobIndexSize = if blobsBig then 4 else 2
+ StringsStreamOffset =
+ stringsStream
+ |> Option.map (fun header -> header.Offset)
+ |> Option.defaultValue 0
+ StringsStreamSize = stringsStream |> Option.map (fun header -> header.Size) |> Option.defaultValue 0
+ BlobStreamOffset = blobOffset
+ }
+
+/// Read a null-terminated string from the #Strings heap.
+let private readStringFromHeap (ctx: MetadataContext) (offset: int) : string =
+ if offset = 0 then
+ ""
+ else
+ let streamStart = int64 ctx.StringsStreamOffset
+ let streamSize = int64 ctx.StringsStreamSize
+ let streamEnd = streamStart + streamSize
+ let stringStart = streamStart + int64 offset
+
+ // Metadata indices are scoped to #Strings, not to the containing PE image.
+ // Failing before decoding prevents malformed offsets from reading an adjacent heap.
+ if
+ offset < 0
+ || streamStart < 0L
+ || streamSize < 0L
+ || streamEnd > int64 ctx.Bytes.Length
+ || stringStart < streamStart
+ || stringStart >= streamEnd
+ then
+ raise (BadImageFormatException("String heap index is outside the #Strings stream."))
+
+ let start = int stringStart
+ let streamEnd = int streamEnd
+ let mutable endPos = start
+
+ while endPos < streamEnd && ctx.Bytes.[endPos] <> 0uy do
+ endPos <- endPos + 1
+
+ if endPos = streamEnd then
+ raise (BadImageFormatException("String heap value is not terminated inside the #Strings stream."))
+
+ System.Text.Encoding.UTF8.GetString(ctx.Bytes, start, endPos - start)
+
+// ============================================================================
+// Table row reading functions
+// ============================================================================
+
+/// MethodDef row data needed for baseline cache.
+type MethodDefRowData =
+ {
+ RVA: int
+ ImplFlags: int
+ Flags: int
+ NameOffset: int
+ SignatureOffset: int
+ ParamList: int // First Param row ID (1-based)
+ }
+
+/// Read a MethodDef row by 1-based row ID.
+let private readMethodDefRow (ctx: MetadataContext) (rowSizes: int[]) (tableOffsets: int[]) (rowId: int) : MethodDefRowData option =
+ if rowId < 1 || rowId > ctx.RowCounts.[TableIndices.MethodDef] then
+ None
+ else
+ let rowSize = rowSizes.[TableIndices.MethodDef]
+ let offset = tableOffsets.[TableIndices.MethodDef] + (rowId - 1) * rowSize
+ let bytes = ctx.Bytes
+
+ // MethodDef: RVA(4) + ImplFlags(2) + Flags(2) + Name(str) + Signature(blob) + ParamList(Param)
+ let rva = readInt32 bytes offset
+ let implFlags = int (readUInt16 bytes (offset + 4))
+ let flags = int (readUInt16 bytes (offset + 6))
+ let nameOffset = readHeapIndex bytes (offset + 8) ctx.StringIndexSize
+
+ let sigOffset =
+ readHeapIndex bytes (offset + 8 + ctx.StringIndexSize) ctx.BlobIndexSize
+
+ let paramList =
+ readHeapIndex bytes (offset + 8 + ctx.StringIndexSize + ctx.BlobIndexSize) (tableIndexSize ctx.RowCounts TableIndices.Param)
+
+ Some
+ {
+ RVA = rva
+ ImplFlags = implFlags
+ Flags = flags
+ NameOffset = nameOffset
+ SignatureOffset = sigOffset
+ ParamList = paramList
+ }
+
+/// Param row data.
+type ParamRowData =
+ {
+ Flags: int
+ Sequence: int
+ NameOffset: int
+ }
+
+/// Read a Param row by 1-based row ID.
+let private readParamRow (ctx: MetadataContext) (rowSizes: int[]) (tableOffsets: int[]) (rowId: int) : ParamRowData option =
+ if rowId < 1 || rowId > ctx.RowCounts.[TableIndices.Param] then
+ None
+ else
+ let rowSize = rowSizes.[TableIndices.Param]
+ let offset = tableOffsets.[TableIndices.Param] + (rowId - 1) * rowSize
+ let bytes = ctx.Bytes
+
+ // Param: Flags(2) + Sequence(2) + Name(str)
+ let flags = int (readUInt16 bytes offset)
+ let sequence = int (readUInt16 bytes (offset + 2))
+ let nameOffset = readHeapIndex bytes (offset + 4) ctx.StringIndexSize
+
+ Some
+ {
+ Flags = flags
+ Sequence = sequence
+ NameOffset = nameOffset
+ }
+
+/// Property row data.
+type PropertyRowData =
+ {
+ Flags: int
+ NameOffset: int
+ SignatureOffset: int
+ }
+
+/// Read a Property row by 1-based row ID.
+let private readPropertyRow (ctx: MetadataContext) (rowSizes: int[]) (tableOffsets: int[]) (rowId: int) : PropertyRowData option =
+ if rowId < 1 || rowId > ctx.RowCounts.[TableIndices.Property] then
+ None
+ else
+ let rowSize = rowSizes.[TableIndices.Property]
+ let offset = tableOffsets.[TableIndices.Property] + (rowId - 1) * rowSize
+ let bytes = ctx.Bytes
+
+ // Property: Flags(2) + Name(str) + Type(blob)
+ let flags = int (readUInt16 bytes offset)
+ let nameOffset = readHeapIndex bytes (offset + 2) ctx.StringIndexSize
+
+ let sigOffset =
+ readHeapIndex bytes (offset + 2 + ctx.StringIndexSize) ctx.BlobIndexSize
+
+ Some
+ {
+ Flags = flags
+ NameOffset = nameOffset
+ SignatureOffset = sigOffset
+ }
+
+/// Event row data.
+type EventRowData =
+ {
+ Flags: int
+ NameOffset: int
+ EventType: int // Coded index (TypeDefOrRef)
+ }
+
+/// Read an Event row by 1-based row ID.
+let private readEventRow (ctx: MetadataContext) (rowSizes: int[]) (tableOffsets: int[]) (rowId: int) : EventRowData option =
+ if rowId < 1 || rowId > ctx.RowCounts.[TableIndices.Event] then
+ None
+ else
+ let rowSize = rowSizes.[TableIndices.Event]
+ let offset = tableOffsets.[TableIndices.Event] + (rowId - 1) * rowSize
+ let bytes = ctx.Bytes
+
+ // Event: EventFlags(2) + Name(str) + EventType(TypeDefOrRef)
+ let flags = int (readUInt16 bytes offset)
+ let nameOffset = readHeapIndex bytes (offset + 2) ctx.StringIndexSize
+
+ Some
+ {
+ Flags = flags
+ NameOffset = nameOffset
+ EventType = 0
+ }
+
+/// TypeRef row data.
+type TypeRefRowData =
+ {
+ ResolutionScope: int // Coded index
+ NameOffset: int
+ NamespaceOffset: int
+ }
+
+/// Read a TypeRef row by 1-based row ID.
+let private readTypeRefRow (ctx: MetadataContext) (rowSizes: int[]) (tableOffsets: int[]) (rowId: int) : TypeRefRowData option =
+ if rowId < 1 || rowId > ctx.RowCounts.[TableIndices.TypeRef] then
+ None
+ else
+ let rowSize = rowSizes.[TableIndices.TypeRef]
+ let offset = tableOffsets.[TableIndices.TypeRef] + (rowId - 1) * rowSize
+ let bytes = ctx.Bytes
+ let resScopeSize = resolutionScopeSize ctx.RowCounts
+
+ // TypeRef: ResolutionScope(coded) + TypeName(str) + TypeNamespace(str)
+ let resScope = readHeapIndex bytes offset resScopeSize
+ let nameOffset = readHeapIndex bytes (offset + resScopeSize) ctx.StringIndexSize
+
+ let nsOffset =
+ readHeapIndex bytes (offset + resScopeSize + ctx.StringIndexSize) ctx.StringIndexSize
+
+ Some
+ {
+ ResolutionScope = resScope
+ NameOffset = nameOffset
+ NamespaceOffset = nsOffset
+ }
+
+/// MemberRef row data.
+type MemberRefRowData =
+ {
+ /// Raw MemberRefParent coded index value (tag bits 0-2, row id above).
+ Parent: int
+ NameOffset: int
+ SignatureOffset: int
+ }
+
+/// Read a MemberRef row by 1-based row ID.
+let private readMemberRefRow (ctx: MetadataContext) (rowSizes: int[]) (tableOffsets: int[]) (rowId: int) : MemberRefRowData option =
+ if rowId < 1 || rowId > ctx.RowCounts.[TableIndices.MemberRef] then
+ None
+ else
+ let rowSize = rowSizes.[TableIndices.MemberRef]
+ let offset = tableOffsets.[TableIndices.MemberRef] + (rowId - 1) * rowSize
+ let bytes = ctx.Bytes
+ let parentSize = memberRefParentSize ctx.RowCounts
+
+ // MemberRef: Class(MemberRefParent) + Name(str) + Signature(blob)
+ let parent = readHeapIndex bytes offset parentSize
+ let nameOffset = readHeapIndex bytes (offset + parentSize) ctx.StringIndexSize
+
+ let sigOffset =
+ readHeapIndex bytes (offset + parentSize + ctx.StringIndexSize) ctx.BlobIndexSize
+
+ Some
+ {
+ Parent = parent
+ NameOffset = nameOffset
+ SignatureOffset = sigOffset
+ }
+
+/// CustomAttribute row data.
+type CustomAttributeRowData =
+ {
+ /// Raw HasCustomAttribute coded index value (tag bits 0-4, row id above).
+ Parent: int
+ /// Raw CustomAttributeType coded index value (tag bits 0-2, row id above).
+ Constructor: int
+ ValueOffset: int
+ }
+
+/// Read a CustomAttribute row by 1-based row ID.
+let private readCustomAttributeRow
+ (ctx: MetadataContext)
+ (rowSizes: int[])
+ (tableOffsets: int[])
+ (rowId: int)
+ : CustomAttributeRowData option =
+ if rowId < 1 || rowId > ctx.RowCounts.[TableIndices.CustomAttribute] then
+ None
+ else
+ let rowSize = rowSizes.[TableIndices.CustomAttribute]
+ let offset = tableOffsets.[TableIndices.CustomAttribute] + (rowId - 1) * rowSize
+ let bytes = ctx.Bytes
+ let parentSize = hasCustomAttributeSize ctx.RowCounts
+ let ctorSize = customAttributeTypeSize ctx.RowCounts
+
+ // CustomAttribute: Parent(HasCustomAttribute) + Type(CustomAttributeType) + Value(blob)
+ let parent = readHeapIndex bytes offset parentSize
+ let ctor = readHeapIndex bytes (offset + parentSize) ctorSize
+
+ let valueOffset =
+ readHeapIndex bytes (offset + parentSize + ctorSize) ctx.BlobIndexSize
+
+ Some
+ {
+ Parent = parent
+ Constructor = ctor
+ ValueOffset = valueOffset
+ }
+
+/// Read a TypeSpec row by 1-based row ID; the row is a single #Blob signature column.
+let private readTypeSpecRow (ctx: MetadataContext) (rowSizes: int[]) (tableOffsets: int[]) (rowId: int) : int option =
+ if rowId < 1 || rowId > ctx.RowCounts.[TableIndices.TypeSpec] then
+ None
+ else
+ let rowSize = rowSizes.[TableIndices.TypeSpec]
+ let offset = tableOffsets.[TableIndices.TypeSpec] + (rowId - 1) * rowSize
+ Some(readHeapIndex ctx.Bytes offset ctx.BlobIndexSize)
+
+/// Read a length-prefixed blob (ECMA-335 II.24.2.4 compressed length) from the #Blob heap.
+let private readBlobFromHeap (ctx: MetadataContext) (offset: int) : byte[] =
+ if offset <= 0 then
+ Array.empty
+ else
+ let start = ctx.BlobStreamOffset + offset
+ let b0 = int ctx.Bytes.[start]
+
+ let length, headerSize =
+ if b0 &&& 0x80 = 0 then
+ b0, 1
+ elif b0 &&& 0xC0 = 0x80 then
+ (((b0 &&& 0x3F) <<< 8) ||| int ctx.Bytes.[start + 1]), 2
+ else
+ (((b0 &&& 0x1F) <<< 24)
+ ||| (int ctx.Bytes.[start + 1] <<< 16)
+ ||| (int ctx.Bytes.[start + 2] <<< 8)
+ ||| int ctx.Bytes.[start + 3]),
+ 4
+
+ if length = 0 then
+ Array.empty
+ else
+ ctx.Bytes.[start + headerSize .. start + headerSize + length - 1]
+
+/// AssemblyRef row data.
+type AssemblyRefRowData =
+ {
+ MajorVersion: int
+ MinorVersion: int
+ BuildNumber: int
+ RevisionNumber: int
+ Flags: int
+ PublicKeyOrToken: int // Blob offset
+ NameOffset: int
+ Culture: int // String offset
+ HashValue: int // Blob offset
+ }
+
+/// Read an AssemblyRef row by 1-based row ID.
+let private readAssemblyRefRow (ctx: MetadataContext) (rowSizes: int[]) (tableOffsets: int[]) (rowId: int) : AssemblyRefRowData option =
+ if rowId < 1 || rowId > ctx.RowCounts.[TableIndices.AssemblyRef] then
+ None
+ else
+ let rowSize = rowSizes.[TableIndices.AssemblyRef]
+ let offset = tableOffsets.[TableIndices.AssemblyRef] + (rowId - 1) * rowSize
+ let bytes = ctx.Bytes
+
+ // AssemblyRef: MajorVersion(2) + MinorVersion(2) + BuildNumber(2) + RevisionNumber(2) +
+ // Flags(4) + PublicKeyOrToken(blob) + Name(str) + Culture(str) + HashValue(blob)
+ let major = int (readUInt16 bytes offset)
+ let minor = int (readUInt16 bytes (offset + 2))
+ let build = int (readUInt16 bytes (offset + 4))
+ let rev = int (readUInt16 bytes (offset + 6))
+ let flags = readInt32 bytes (offset + 8)
+ let pkOffset = readHeapIndex bytes (offset + 12) ctx.BlobIndexSize
+
+ let nameOffset =
+ readHeapIndex bytes (offset + 12 + ctx.BlobIndexSize) ctx.StringIndexSize
+
+ let cultureOffset =
+ readHeapIndex bytes (offset + 12 + ctx.BlobIndexSize + ctx.StringIndexSize) ctx.StringIndexSize
+
+ let hashOffset =
+ readHeapIndex bytes (offset + 12 + ctx.BlobIndexSize + ctx.StringIndexSize + ctx.StringIndexSize) ctx.BlobIndexSize
+
+ Some
+ {
+ MajorVersion = major
+ MinorVersion = minor
+ BuildNumber = build
+ RevisionNumber = rev
+ Flags = flags
+ PublicKeyOrToken = pkOffset
+ NameOffset = nameOffset
+ Culture = cultureOffset
+ HashValue = hashOffset
+ }
+
+/// Module row data (including name offset).
+type ModuleRowData =
+ {
+ Generation: int
+ NameOffset: int
+ MvidIndex: int
+ EncIdIndex: int
+ EncBaseIdIndex: int
+ }
+
+/// Read the Module row (there's only one, row 1).
+let private readModuleRow (ctx: MetadataContext) (_rowSizes: int[]) (tableOffsets: int[]) : ModuleRowData option =
+ if ctx.RowCounts.[TableIndices.Module] < 1 then
+ None
+ else
+ let offset = tableOffsets.[TableIndices.Module]
+ let bytes = ctx.Bytes
+
+ // Module: Generation(2) + Name(str) + Mvid(guid) + EncId(guid) + EncBaseId(guid)
+ let generation = int (readUInt16 bytes offset)
+ let nameOffset = readHeapIndex bytes (offset + 2) ctx.StringIndexSize
+
+ let mvidIndex =
+ readHeapIndex bytes (offset + 2 + ctx.StringIndexSize) ctx.GuidIndexSize
+
+ let encIdIndex =
+ readHeapIndex bytes (offset + 2 + ctx.StringIndexSize + ctx.GuidIndexSize) ctx.GuidIndexSize
+
+ let encBaseIdIndex =
+ readHeapIndex bytes (offset + 2 + ctx.StringIndexSize + ctx.GuidIndexSize + ctx.GuidIndexSize) ctx.GuidIndexSize
+
+ Some
+ {
+ Generation = generation
+ NameOffset = nameOffset
+ MvidIndex = mvidIndex
+ EncIdIndex = encIdIndex
+ EncBaseIdIndex = encBaseIdIndex
+ }
+
+// ============================================================================
+// Public API for baseline metadata extraction
+// ============================================================================
+
+/// Baseline metadata reader that provides access to table rows without SRM.
+type BaselineMetadataReader private (ctx: MetadataContext, rowSizes: int[], tableOffsets: int[]) =
+
+ /// Create a reader from PE file bytes.
+ static member Create(bytes: byte[]) : BaselineMetadataReader option =
+ match createMetadataContext bytes with
+ | None -> None
+ | Some ctx ->
+ let rowSizes = calculateTableRowSizes ctx
+ let tableOffsets = calculateTableOffsets ctx rowSizes
+ Some(BaselineMetadataReader(ctx, rowSizes, tableOffsets))
+
+ /// Get the table row counts.
+ member _.RowCounts = ctx.RowCounts
+
+ /// Read a MethodDef row by 1-based row ID.
+ member _.GetMethodDef(rowId: int) =
+ readMethodDefRow ctx rowSizes tableOffsets rowId
+
+ /// Read a Param row by 1-based row ID.
+ member _.GetParam(rowId: int) =
+ readParamRow ctx rowSizes tableOffsets rowId
+
+ /// Get the last param row for a method (based on next method's ParamList or table end).
+ member this.GetMethodParamRange(methodRowId: int) : (int * int) option =
+ match this.GetMethodDef(methodRowId) with
+ | None -> None
+ | Some methodDef ->
+ let firstParam = methodDef.ParamList
+
+ let lastParam =
+ if methodRowId < ctx.RowCounts.[TableIndices.MethodDef] then
+ match this.GetMethodDef(methodRowId + 1) with
+ | Some next -> next.ParamList - 1
+ | None -> ctx.RowCounts.[TableIndices.Param]
+ else
+ ctx.RowCounts.[TableIndices.Param]
+
+ if firstParam > lastParam then
+ None
+ else
+ Some(firstParam, lastParam)
+
+ /// Read a Property row by 1-based row ID.
+ member _.GetProperty(rowId: int) =
+ readPropertyRow ctx rowSizes tableOffsets rowId
+
+ /// Read an Event row by 1-based row ID.
+ member _.GetEvent(rowId: int) =
+ readEventRow ctx rowSizes tableOffsets rowId
+
+ /// Read a TypeRef row by 1-based row ID.
+ member _.GetTypeRef(rowId: int) =
+ readTypeRefRow ctx rowSizes tableOffsets rowId
+
+ /// Read an AssemblyRef row by 1-based row ID.
+ member _.GetAssemblyRef(rowId: int) =
+ readAssemblyRefRow ctx rowSizes tableOffsets rowId
+
+ /// Get the AssemblyRef row count.
+ member _.AssemblyRefCount = ctx.RowCounts.[TableIndices.AssemblyRef]
+
+ /// Get the TypeRef row count.
+ member _.TypeRefCount = ctx.RowCounts.[TableIndices.TypeRef]
+
+ /// Read the Module row.
+ member _.GetModule() = readModuleRow ctx rowSizes tableOffsets
+
+ /// Read a string from the #Strings heap.
+ member _.GetString(offset: int) = readStringFromHeap ctx offset
+
+ /// Read a MemberRef row by 1-based row ID.
+ member _.GetMemberRef(rowId: int) =
+ readMemberRefRow ctx rowSizes tableOffsets rowId
+
+ /// Get the MemberRef row count.
+ member _.MemberRefCount = ctx.RowCounts.[TableIndices.MemberRef]
+
+ /// Read a TypeSpec row's signature blob offset by 1-based row ID.
+ member _.GetTypeSpecSignatureOffset(rowId: int) =
+ readTypeSpecRow ctx rowSizes tableOffsets rowId
+
+ /// Get the TypeSpec row count.
+ member _.TypeSpecCount = ctx.RowCounts.[TableIndices.TypeSpec]
+
+ /// Read a length-prefixed blob from the #Blob heap.
+ member _.GetBlob(offset: int) = readBlobFromHeap ctx offset
+
+ /// Read a CustomAttribute row by 1-based row ID.
+ member _.GetCustomAttributeRow(rowId: int) =
+ readCustomAttributeRow ctx rowSizes tableOffsets rowId
+
+ /// Get the CustomAttribute row count.
+ member _.CustomAttributeCount = ctx.RowCounts.[TableIndices.CustomAttribute]
+
+ /// Decode a HasCustomAttribute coded index to a metadata token.
+ /// Tag bits (5), ECMA-335 II.24.2.6 ordering.
+ member _.DecodeHasCustomAttributeToken(codedIndex: int) : int =
+ let tag = codedIndex &&& 0x1F
+ let rowId = codedIndex >>> 5
+
+ let table =
+ match tag with
+ | 0 -> 0x06 // MethodDef
+ | 1 -> 0x04 // Field
+ | 2 -> 0x01 // TypeRef
+ | 3 -> 0x02 // TypeDef
+ | 4 -> 0x08 // Param
+ | 5 -> 0x09 // InterfaceImpl
+ | 6 -> 0x0A // MemberRef
+ | 7 -> 0x00 // Module
+ | 8 -> 0x0E // DeclSecurity
+ | 9 -> 0x17 // Property
+ | 10 -> 0x14 // Event
+ | 11 -> 0x11 // StandAloneSig
+ | 12 -> 0x1A // ModuleRef
+ | 13 -> 0x1B // TypeSpec
+ | 14 -> 0x20 // Assembly
+ | 15 -> 0x23 // AssemblyRef
+ | 16 -> 0x26 // File
+ | 17 -> 0x27 // ExportedType
+ | 18 -> 0x28 // ManifestResource
+ | 19 -> 0x2A // GenericParam
+ | 20 -> 0x2C // GenericParamConstraint
+ | _ -> 0x2B // MethodSpec
+
+ (table <<< 24) ||| rowId
+
+ /// Decode a CustomAttributeType coded index to a metadata token.
+ /// Tag bits (3): 2=MethodDef, 3=MemberRef.
+ member _.DecodeCustomAttributeTypeToken(codedIndex: int) : int =
+ let tag = codedIndex &&& 0x7
+ let rowId = codedIndex >>> 3
+
+ let table =
+ match tag with
+ | 2 -> 0x06 // MethodDef
+ | _ -> 0x0A // MemberRef
+
+ (table <<< 24) ||| rowId
+
+ /// Decode a MemberRefParent coded index to a metadata token.
+ /// Tag bits (3): 0=TypeDef, 1=TypeRef, 2=ModuleRef, 3=MethodDef, 4=TypeSpec.
+ member _.DecodeMemberRefParentToken(codedIndex: int) : int =
+ let tag = codedIndex &&& 0x7
+ let rowId = codedIndex >>> 3
+
+ let tableIndex =
+ match tag with
+ | 0 -> TableIndices.TypeDef
+ | 1 -> TableIndices.TypeRef
+ | 2 -> TableIndices.ModuleRef
+ | 3 -> TableIndices.MethodDef
+ | 4 -> TableIndices.TypeSpec
+ | _ -> -1
+
+ if tableIndex < 0 then 0 else (tableIndex <<< 24) ||| rowId
+
+ /// Decode ResolutionScope coded index to (table index, row id).
+ /// Tag bits: 0=Module, 1=ModuleRef, 2=AssemblyRef, 3=TypeRef
+ member _.DecodeResolutionScope(codedIndex: int) : (int * int) =
+ let tag = codedIndex &&& 0x3
+ let rowId = codedIndex >>> 2
+
+ let tableIndex =
+ match tag with
+ | 0 -> TableIndices.Module
+ | 1 -> TableIndices.ModuleRef
+ | 2 -> TableIndices.AssemblyRef
+ | 3 -> TableIndices.TypeRef
+ | _ -> -1
+
+ (tableIndex, rowId)
+
+/// Read Module.Mvid GUID from assembly bytes.
+/// Module table row 1 contains the Mvid index.
+let readModuleMvidFromBytes (bytes: byte[]) : System.Guid option =
+ match findMetadataRoot bytes with
+ | None -> None
+ | Some metadataRoot ->
+ let streamHeaders = parseStreamHeaders bytes metadataRoot
+
+ let tablesStreamOpt =
+ findStream streamHeaders "#~" |> Option.orElse (findStream streamHeaders "#-")
+
+ match tablesStreamOpt with
+ | None -> None
+ | Some tablesStream ->
+ let heapSizes, rowCounts, tablesOffset, valid = parseTablesStream bytes tablesStream
+
+ // Check if Module table has at least 1 row
+ if rowCounts.[0] < 1 then
+ None
+ else
+ // Calculate offset to Module row
+ // Module row structure: Generation (2), Name (string), Mvid (guid), EncId (guid), EncBaseId (guid)
+ let stringsBig = (heapSizes &&& 0x01uy) <> 0uy
+ let guidsBig = (heapSizes &&& 0x02uy) <> 0uy
+
+ let stringIndexSize = if stringsBig then 4 else 2
+
+ // Row-count entries are present for every valid table, including zero-row tables.
+ let tablesStart = tableDataStart tablesOffset valid
+
+ // Module table is table 0, so it starts at tablesStart
+ // Module row: Generation (2) + Name (string index) + Mvid (guid index) + EncId (guid index) + EncBaseId (guid index)
+ let mvidOffset = tablesStart + 2 + stringIndexSize
+
+ let mvidIndex =
+ if guidsBig then
+ readInt32 bytes mvidOffset
+ else
+ int (readUInt16 bytes mvidOffset)
+
+ readGuidFromBytes bytes mvidIndex
+
+// ============================================================================
+// Portable PDB Reader
+// ============================================================================
+
+/// Portable PDB table indices (start at 0x30 to avoid collision with ECMA-335 tables)
+module private PdbTableIndices =
+ let Document = 0x30
+ let MethodDebugInformation = 0x31
+ let LocalScope = 0x32
+ let LocalVariable = 0x33
+ let LocalConstant = 0x34
+ let ImportScope = 0x35
+ let StateMachineMethod = 0x36
+ let CustomDebugInformation = 0x37
+
+/// Portable PDB metadata snapshot.
+/// Contains table row counts and entry point info for hot reload baseline.
+type PortablePdbMetadata =
+ {
+ /// Content ID stored in the #Pdb stream.
+ ContentId: byte[]
+ /// Row counts for PDB tables (indexed by PDB table index - 0x30)
+ /// Index 0 = Document, 1 = MethodDebugInformation, etc.
+ TableRowCounts: int[]
+ /// Entry point method token (if present)
+ EntryPointToken: int option
+ }
+
+/// Parse the #Pdb stream to extract PDB-specific info.
+/// The #Pdb stream contains: PdbId (20 bytes), EntryPoint token (4 bytes), ReferencedTypeSystemTables (8 bytes), TypeSystemTableRows (var)
+let private parsePdbStream (bytes: byte[]) (pdbStream: StreamHeader) : int option =
+ if pdbStream.Size < 24 then
+ None
+ else
+ let offset = pdbStream.Offset
+ // PdbId: 20 bytes (GUID + 4 bytes stamp)
+ // EntryPoint: 4 bytes (method def token, or 0 if no entry point)
+ let entryPointToken = readInt32 bytes (offset + 20)
+ if entryPointToken = 0 then None else Some entryPointToken
+
+/// Parse Portable PDB table row counts from the #~ stream.
+/// Portable PDB uses tables 0x30-0x37, but the valid bits are still in position 0x30+.
+let private parsePdbTablesStream (bytes: byte[]) (tablesStream: StreamHeader) : int[] =
+ let offset = tablesStream.Offset
+
+ // Header: Reserved(4) + MajorVersion(1) + MinorVersion(1) + HeapSizes(1) + Reserved(1) + Valid(8) + Sorted(8) + RowCounts(var)
+ let valid = readUInt64 bytes (offset + 8)
+
+ // PDB table row counts (8 tables, indices 0x30-0x37)
+ let pdbRowCounts = Array.zeroCreate 8
+ let mutable rowCountOffset = offset + 24
+
+ for i in 0..63 do
+ if (valid &&& (1UL <<< i)) <> 0UL then
+ let count = readInt32 bytes rowCountOffset
+ // Map table index to PDB array index
+ if i >= 0x30 && i <= 0x37 then
+ pdbRowCounts.[i - 0x30] <- count
+
+ rowCountOffset <- rowCountOffset + 4
+
+ pdbRowCounts
+
+/// Extract metadata from Portable PDB bytes.
+/// This replaces MetadataReaderProvider.FromPortablePdbImage for hot reload baseline creation.
+let readPortablePdbMetadata (pdbBytes: byte[]) : PortablePdbMetadata option =
+ // Portable PDB starts directly with metadata root (no PE header)
+ // Check for BSJB signature at offset 0
+ if pdbBytes.Length < 4 then
+ None
+ else
+ try
+ let signature = readInt32 pdbBytes 0
+
+ if signature <> 0x424A5342 then // "BSJB"
+ None
+ else
+ // Parse from offset 0 (metadata root)
+ let metadataRoot = 0
+ let streamHeaders = parseStreamHeaders pdbBytes metadataRoot
+
+ // Find required streams
+ let tablesStreamOpt =
+ findStream streamHeaders "#~" |> Option.orElse (findStream streamHeaders "#-")
+
+ let pdbStreamOpt = findStream streamHeaders "#Pdb"
+
+ match tablesStreamOpt, pdbStreamOpt with
+ | Some tablesStream, Some pdbStream when pdbStream.Size >= 24 ->
+ let rowCounts = parsePdbTablesStream pdbBytes tablesStream
+ let entryPoint = parsePdbStream pdbBytes pdbStream
+
+ Some
+ {
+ ContentId = pdbBytes.[pdbStream.Offset .. pdbStream.Offset + 19]
+ TableRowCounts = rowCounts
+ EntryPointToken = entryPoint
+ }
+ | _ -> None
+ with
+ | :? System.IndexOutOfRangeException -> None
+ | :? System.ArgumentOutOfRangeException -> None
diff --git a/src/Compiler/AbstractIL/ILMetadataHeaps.fs b/src/Compiler/AbstractIL/ILMetadataHeaps.fs
index 7c6ffe3a86c..5c85fc57489 100644
--- a/src/Compiler/AbstractIL/ILMetadataHeaps.fs
+++ b/src/Compiler/AbstractIL/ILMetadataHeaps.fs
@@ -1,9 +1,8 @@
// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
/// Abstractions for metadata heap indexing.
-/// Used by full assembly emission (ilwrite.fs) and intended to also back the delta
-/// emitter tracked in F# hot-reload work (dotnet/fsharp#19941), providing a unified
-/// interface for string, blob, GUID, and user-string heap access.
+/// Used by both full assembly emission (ilwrite.fs) and delta emission (IlxDeltaEmitter.fs)
+/// to provide a unified interface for string, blob, GUID, and user-string heap access.
module internal FSharp.Compiler.AbstractIL.ILMetadataHeaps
/// Abstraction for metadata heap indexing operations.
@@ -33,22 +32,3 @@ module MetadataHeapsExtensions =
match sopt with
| Some s -> this.GetStringHeapIdx s
| None -> 0
-
-///
-/// Records the uncompressed heap sizes produced during metadata emission so that later delta passes
-/// can reason about stream growth.
-///
-///
-/// This type is delta-owned: the full-assembly IL writer (ilwrite.fs) does not currently expose an
-/// equivalent snapshot type on main. Keeping the definition here (rather than growing ilwrite.fsi's
-/// public surface) lets the delta writer stay self-contained; a future PR that wires a baseline
-/// producer into this writer can either reuse this type directly or convert into it at the boundary.
-///
-[]
-type MetadataHeapSizes =
- {
- StringHeapSize: int
- UserStringHeapSize: int
- BlobHeapSize: int
- GuidHeapSize: int
- }
diff --git a/src/Compiler/AbstractIL/IlxDeltaStreams.fs b/src/Compiler/AbstractIL/IlxDeltaStreams.fs
index 9d95c9c2ba2..d8a83f386c3 100644
--- a/src/Compiler/AbstractIL/IlxDeltaStreams.fs
+++ b/src/Compiler/AbstractIL/IlxDeltaStreams.fs
@@ -112,29 +112,18 @@ type IlDeltaStreams =
StandaloneSignatures: StandaloneSignatureUpdate list
}
+type private SeededOffsets = SeededOffsets
+
///
/// Accumulates metadata tables, Edit-and-Continue bookkeeping, and encoded method bodies prior to serialising
/// a hot reload delta. Uses pure F# token calculators instead of SRM MetadataBuilder.
/// Callers retrieve the resulting byte arrays via .
///
-///
-/// Baseline #US heap size (bytes) to seed the user-string token calculator, or 0 for a baseline-less builder.
-///
-///
-/// Baseline StandAloneSig table row count to seed standalone signature row numbering, or 0 for a baseline-less
-/// builder.
-///
-///
-/// The feature branch this was extracted from seeds these values from an ilwrite-produced baseline snapshot
-/// type. That snapshot type is part of a larger, not-yet-upstreamed baseline-capture change to ilwrite.fs/.fsi,
-/// so it is intentionally out of scope here; callers that have such a snapshot should pass its two relevant
-/// fields (heap size / row count) directly.
-///
-type IlDeltaStreamBuilder(initialUserStringHeapSize: int, initialStandAloneSigRowCount: int) =
- let userStringCalculator = UserStringTokenCalculator(initialUserStringHeapSize)
+type IlDeltaStreamBuilder private (userStringHeapStart: int, standaloneSigRowCount: int, _seededOffsets: SeededOffsets) =
+ let userStringCalculator = UserStringTokenCalculator(userStringHeapStart)
let standaloneSigCalculator =
- StandaloneSignatureTokenCalculator(initialStandAloneSigRowCount)
+ StandaloneSignatureTokenCalculator(standaloneSigRowCount)
let methodBodyStream = ByteBuffer.Create(256)
let methodBodies = ResizeArray()
@@ -148,8 +137,22 @@ type IlDeltaStreamBuilder(initialUserStringHeapSize: int, initialStandAloneSigRo
for _ = 1 to padding do
methodBodyStream.EmitByte 0uy
- /// Construct a builder with no baseline (generation-1 / test scenarios).
- new() = IlDeltaStreamBuilder(0, 0)
+ /// Create a builder seeded from an emitted baseline metadata snapshot, when one is available.
+ new(baselineMetadata: MetadataSnapshot option) =
+ let userStringHeapStart, standaloneSigRowCount =
+ match baselineMetadata with
+ | Some snapshot -> snapshot.HeapSizes.UserStringHeapSize, snapshot.TableRowCounts.[TableNames.StandAloneSig.Index]
+ | None -> 0, 0
+
+ IlDeltaStreamBuilder(userStringHeapStart, standaloneSigRowCount, SeededOffsets)
+
+ /// Create an unseeded builder for a first-generation or isolated metadata delta.
+ new() = IlDeltaStreamBuilder(0, 0, SeededOffsets)
+
+ /// Create a builder seeded with explicit baseline heap and table sizes.
+ new(initialUserStringHeapSize: int, initialStandAloneSigRowCount: int) =
+ // Lower metadata-writer tests use explicit offsets without constructing a full baseline snapshot.
+ IlDeltaStreamBuilder(initialUserStringHeapSize, initialStandAloneSigRowCount, SeededOffsets)
/// Expose the user string token calculator for advanced scenarios.
member _.UserStringCalculator = userStringCalculator
diff --git a/src/Compiler/AbstractIL/ilwrite.fs b/src/Compiler/AbstractIL/ilwrite.fs
index bf6277bf485..1351e3ab05b 100644
--- a/src/Compiler/AbstractIL/ilwrite.fs
+++ b/src/Compiler/AbstractIL/ilwrite.fs
@@ -11,6 +11,7 @@ open FSharp.Compiler.AbstractIL.IL
open FSharp.Compiler.AbstractIL.Diagnostics
open FSharp.Compiler.AbstractIL.BinaryConstants
open FSharp.Compiler.AbstractIL.Support
+open FSharp.Compiler.AbstractIL.ILMetadataHeaps
open Internal.Utilities.Library
open FSharp.Compiler.AbstractIL.StrongNameSign
open FSharp.Compiler.AbstractIL.ILPdbWriter
@@ -162,59 +163,59 @@ module RowElementTags =
let [] Blob = 5
let [] String = 6
let [] SimpleIndexMin = 7
- let SimpleIndex (t : TableName) = assert (t.Index <= 112); SimpleIndexMin + t.Index
+ let SimpleIndex (table: TableName) = assert (table.Index <= 112); SimpleIndexMin + table.Index
let [] SimpleIndexMax = 119
let [] TypeDefOrRefOrSpecMin = 120
- let TypeDefOrRefOrSpec (t: TypeDefOrRefTag) = assert (t.Tag <= 2); TypeDefOrRefOrSpecMin + t.Tag (* + 111 + 1 = 0x70 + 1 = max TableName.Tndex + 1 *)
+ let TypeDefOrRefOrSpec (tag: TypeDefOrRefTag) = assert (tag.Tag <= 2); TypeDefOrRefOrSpecMin + tag.Tag (* + 111 + 1 = 0x70 + 1 = max TableName.Tndex + 1 *)
let [] TypeDefOrRefOrSpecMax = 122
let [] TypeOrMethodDefMin = 123
- let TypeOrMethodDef (t: TypeOrMethodDefTag) = assert (t.Tag <= 1); TypeOrMethodDefMin + t.Tag (* + 2 + 1 = max TypeDefOrRefOrSpec.Tag + 1 *)
+ let TypeOrMethodDef (tag: TypeOrMethodDefTag) = assert (tag.Tag <= 1); TypeOrMethodDefMin + tag.Tag (* + 2 + 1 = max TypeDefOrRefOrSpec.Tag + 1 *)
let [] TypeOrMethodDefMax = 124
let [] HasConstantMin = 125
- let HasConstant (t: HasConstantTag) = assert (t.Tag <= 2); HasConstantMin + t.Tag (* + 1 + 1 = max TypeOrMethodDef.Tag + 1 *)
+ let HasConstant (tag: HasConstantTag) = assert (tag.Tag <= 2); HasConstantMin + tag.Tag (* + 1 + 1 = max TypeOrMethodDef.Tag + 1 *)
let [] HasConstantMax = 127
let [] HasCustomAttributeMin = 128
- let HasCustomAttribute (t: HasCustomAttributeTag) = assert (t.Tag <= 21); HasCustomAttributeMin + t.Tag (* + 2 + 1 = max HasConstant.Tag + 1 *)
+ let HasCustomAttribute (tag: HasCustomAttributeTag) = assert (tag.Tag <= 21); HasCustomAttributeMin + tag.Tag (* + 2 + 1 = max HasConstant.Tag + 1 *)
let [] HasCustomAttributeMax = 149
let [] HasFieldMarshalMin = 150
- let HasFieldMarshal (t: HasFieldMarshalTag) = assert (t.Tag <= 1); HasFieldMarshalMin + t.Tag (* + 21 + 1 = max HasCustomAttribute.Tag + 1 *)
+ let HasFieldMarshal (tag: HasFieldMarshalTag) = assert (tag.Tag <= 1); HasFieldMarshalMin + tag.Tag (* + 21 + 1 = max HasCustomAttribute.Tag + 1 *)
let [] HasFieldMarshalMax = 151
let [] HasDeclSecurityMin = 152
- let HasDeclSecurity (t: HasDeclSecurityTag) = assert (t.Tag <= 2); HasDeclSecurityMin + t.Tag (* + 1 + 1 = max HasFieldMarshal.Tag + 1 *)
+ let HasDeclSecurity (tag: HasDeclSecurityTag) = assert (tag.Tag <= 2); HasDeclSecurityMin + tag.Tag (* + 1 + 1 = max HasFieldMarshal.Tag + 1 *)
let [] HasDeclSecurityMax = 154
let [] MemberRefParentMin = 155
- let MemberRefParent (t: MemberRefParentTag) = assert (t.Tag <= 4); MemberRefParentMin + t.Tag (* + 2 + 1 = max HasDeclSecurity.Tag + 1 *)
+ let MemberRefParent (tag: MemberRefParentTag) = assert (tag.Tag <= 4); MemberRefParentMin + tag.Tag (* + 2 + 1 = max HasDeclSecurity.Tag + 1 *)
let [] MemberRefParentMax = 159
let [] HasSemanticsMin = 160
- let HasSemantics (t: HasSemanticsTag) = assert (t.Tag <= 1); HasSemanticsMin + t.Tag (* + 4 + 1 = max MemberRefParent.Tag + 1 *)
+ let HasSemantics (tag: HasSemanticsTag) = assert (tag.Tag <= 1); HasSemanticsMin + tag.Tag (* + 4 + 1 = max MemberRefParent.Tag + 1 *)
let [] HasSemanticsMax = 161
let [] MethodDefOrRefMin = 162
- let MethodDefOrRef (t: MethodDefOrRefTag) = assert (t.Tag <= 2); MethodDefOrRefMin + t.Tag (* + 1 + 1 = max HasSemantics.Tag + 1 *)
+ let MethodDefOrRef (tag: MethodDefOrRefTag) = assert (tag.Tag <= 2); MethodDefOrRefMin + tag.Tag (* + 1 + 1 = max HasSemantics.Tag + 1 *)
let [] MethodDefOrRefMax = 164
let [] MemberForwardedMin = 165
- let MemberForwarded (t: MemberForwardedTag) = assert (t.Tag <= 1); MemberForwardedMin + t.Tag (* + 2 + 1 = max MethodDefOrRef.Tag + 1 *)
+ let MemberForwarded (tag: MemberForwardedTag) = assert (tag.Tag <= 1); MemberForwardedMin + tag.Tag (* + 2 + 1 = max MethodDefOrRef.Tag + 1 *)
let [] MemberForwardedMax = 166
let [] ImplementationMin = 167
- let Implementation (t: ImplementationTag) = assert (t.Tag <= 2); ImplementationMin + t.Tag (* + 1 + 1 = max MemberForwarded.Tag + 1 *)
+ let Implementation (tag: ImplementationTag) = assert (tag.Tag <= 2); ImplementationMin + tag.Tag (* + 1 + 1 = max MemberForwarded.Tag + 1 *)
let [] ImplementationMax = 169
let [] CustomAttributeTypeMin = 170
- let CustomAttributeType (t: CustomAttributeTypeTag) = assert (t.Tag <= 3); CustomAttributeTypeMin + t.Tag (* + 2 + 1 = max Implementation.Tag + 1 *)
+ let CustomAttributeType (tag: CustomAttributeTypeTag) = assert (tag.Tag <= 3); CustomAttributeTypeMin + tag.Tag (* + 2 + 1 = max Implementation.Tag + 1 *)
let [] CustomAttributeTypeMax = 173
let [] ResolutionScopeMin = 174
- let ResolutionScope (t: ResolutionScopeTag) = assert (t.Tag <= 4); ResolutionScopeMin + t.Tag (* + 3 + 1 = max CustomAttributeType.Tag + 1 *)
+ let ResolutionScope (tag: ResolutionScopeTag) = assert (tag.Tag <= 4); ResolutionScopeMin + tag.Tag (* + 3 + 1 = max CustomAttributeType.Tag + 1 *)
let [] ResolutionScopeMax = 178
[]
@@ -242,33 +243,33 @@ let Blob (x: int) = RowElement(RowElementTags.Blob, x)
let StringE (x: int) = RowElement(RowElementTags.String, x)
/// pos. in some table
-let SimpleIndex (t, x: int) = RowElement(RowElementTags.SimpleIndex t, x)
+let SimpleIndex (table, index: int) = RowElement(RowElementTags.SimpleIndex table, index)
-let TypeDefOrRefOrSpec (t, x: int) = RowElement(RowElementTags.TypeDefOrRefOrSpec t, x)
+let TypeDefOrRefOrSpec (tag, index: int) = RowElement(RowElementTags.TypeDefOrRefOrSpec tag, index)
-let TypeOrMethodDef (t, x: int) = RowElement(RowElementTags.TypeOrMethodDef t, x)
+let TypeOrMethodDef (tag, index: int) = RowElement(RowElementTags.TypeOrMethodDef tag, index)
-let HasConstant (t, x: int) = RowElement(RowElementTags.HasConstant t, x)
+let HasConstant (tag, index: int) = RowElement(RowElementTags.HasConstant tag, index)
-let HasCustomAttribute (t, x: int) = RowElement(RowElementTags.HasCustomAttribute t, x)
+let HasCustomAttribute (tag, index: int) = RowElement(RowElementTags.HasCustomAttribute tag, index)
-let HasFieldMarshal (t, x: int) = RowElement(RowElementTags.HasFieldMarshal t, x)
+let HasFieldMarshal (tag, index: int) = RowElement(RowElementTags.HasFieldMarshal tag, index)
-let HasDeclSecurity (t, x: int) = RowElement(RowElementTags.HasDeclSecurity t, x)
+let HasDeclSecurity (tag, index: int) = RowElement(RowElementTags.HasDeclSecurity tag, index)
-let MemberRefParent (t, x: int) = RowElement(RowElementTags.MemberRefParent t, x)
+let MemberRefParent (tag, index: int) = RowElement(RowElementTags.MemberRefParent tag, index)
-let HasSemantics (t, x: int) = RowElement(RowElementTags.HasSemantics t, x)
+let HasSemantics (tag, index: int) = RowElement(RowElementTags.HasSemantics tag, index)
-let MethodDefOrRef (t, x: int) = RowElement(RowElementTags.MethodDefOrRef t, x)
+let MethodDefOrRef (tag, index: int) = RowElement(RowElementTags.MethodDefOrRef tag, index)
-let MemberForwarded (t, x: int) = RowElement(RowElementTags.MemberForwarded t, x)
+let MemberForwarded (tag, index: int) = RowElement(RowElementTags.MemberForwarded tag, index)
-let Implementation (t, x: int) = RowElement(RowElementTags.Implementation t, x)
+let Implementation (tag, index: int) = RowElement(RowElementTags.Implementation tag, index)
-let CustomAttributeType (t, x: int) = RowElement(RowElementTags.CustomAttributeType t, x)
+let CustomAttributeType (tag, index: int) = RowElement(RowElementTags.CustomAttributeType tag, index)
-let ResolutionScope (t, x: int) = RowElement(RowElementTags.ResolutionScope t, x)
+let ResolutionScope (tag, index: int) = RowElement(RowElementTags.ResolutionScope tag, index)
type BlobIndex = int
@@ -361,57 +362,55 @@ let envForOverrideSpec (ospec: ILOverridesSpec) = { EnclosingTyparCount=ospec.De
// TABLES
//---------------------------------------------------------------------
-[]
-type MetadataTable<'T when 'T:not null> =
- { name: string
- dict: Dictionary<'T, int> // given a row, find its entry number
- mutable rows: ResizeArray<'T> }
+[]
+type MetadataTable<'T when 'T:not null>(name: string, hashEq: IEqualityComparer<'T>) =
+ let dict = Dictionary<'T, int>(100, hashEq)
+ let rows = ResizeArray<'T>()
+
+ member _.Count = rows.Count
+
+ member internal _.Name = name
- member x.Count = x.rows.Count
+ static member New(nm, hashEq) = MetadataTable<'T>(nm, hashEq)
- static member New(nm, hashEq) =
- { name=nm
- dict = Dictionary<_, _>(100, hashEq)
- rows= ResizeArray<_>() }
+ member _.EntriesAsArray = rows |> ResizeArray.toArray
- member tbl.EntriesAsArray =
- tbl.rows |> ResizeArray.toArray
+ member _.Entries = rows |> ResizeArray.toList
- member tbl.Entries =
- tbl.rows |> ResizeArray.toList
+ member internal _.KeyValueSeq = dict :> seq>
- member tbl.AddSharedEntry x =
- let n = tbl.rows.Count + 1
- tbl.dict[x] <- n
- tbl.rows.Add x
+ member _.AddSharedEntry x =
+ let n = rows.Count + 1
+ dict[x] <- n
+ rows.Add x
n
- member tbl.AddUnsharedEntry x =
- let n = tbl.rows.Count + 1
- tbl.rows.Add x
+ member _.AddUnsharedEntry x =
+ let n = rows.Count + 1
+ rows.Add x
n
- member tbl.FindOrAddSharedEntry x =
- match tbl.dict.TryGetValue x with
+ member this.FindOrAddSharedEntry x =
+ match dict.TryGetValue x with
| true, res -> res
- | _ -> tbl.AddSharedEntry x
+ | _ -> this.AddSharedEntry x
- member tbl.Contains x = tbl.dict.ContainsKey x
+ member _.Contains x = dict.ContainsKey x
/// This is only used in one special place - see further below.
- member tbl.SetRowsOfTable t =
- tbl.rows <- ResizeArray.ofArray t
- let h = tbl.dict
- h.Clear()
- t |> Array.iteri (fun i x -> h[x] <- (i+1))
+ member _.SetRowsOfTable(t: 'T[]) =
+ rows.Clear()
+ dict.Clear()
+ t |> Array.iter (fun entry -> rows.Add entry)
+ t |> Array.iteri (fun i entry -> dict[entry] <- i + 1)
- member tbl.AddUniqueEntry nm getter x =
- if tbl.dict.ContainsKey x then failwith ("duplicate entry '"+getter x+"' in "+nm+" table")
- else tbl.AddSharedEntry x
+ member this.AddUniqueEntry nm getter x =
+ if dict.ContainsKey x then failwith ("duplicate entry '" + getter x + "' in " + nm + " table")
+ else this.AddSharedEntry x
- member tbl.GetTableEntry x = tbl.dict[x]
+ member _.GetTableEntry x = dict[x]
- override x.ToString() = "table " + x.name
+ override _.ToString() = "table " + name
//---------------------------------------------------------------------
// Keys into some of the tables
@@ -504,11 +503,11 @@ type TypeDefTableKey = TdKey of string list (* enclosing *) * string (* type nam
type MetadataTable =
| Shared of MetadataTable
| Unshared of MetadataTable
- member t.FindOrAddSharedEntry x = match t with Shared u -> u.FindOrAddSharedEntry x | Unshared u -> failwithf "FindOrAddSharedEntry: incorrect table kind, u.name = %s" u.name
- member t.AddSharedEntry x = match t with | Shared u -> u.AddSharedEntry x | Unshared u -> failwithf "AddSharedEntry: incorrect table kind, u.name = %s" u.name
- member t.AddUnsharedEntry x = match t with Unshared u -> u.AddUnsharedEntry x | Shared u -> failwithf "AddUnsharedEntry: incorrect table kind, u.name = %s" u.name
+ member t.FindOrAddSharedEntry x = match t with Shared u -> u.FindOrAddSharedEntry x | Unshared u -> failwithf "FindOrAddSharedEntry: incorrect table kind, u.Name = %s" u.Name
+ member t.AddSharedEntry x = match t with | Shared u -> u.AddSharedEntry x | Unshared u -> failwithf "AddSharedEntry: incorrect table kind, u.Name = %s" u.Name
+ member t.AddUnsharedEntry x = match t with Unshared u -> u.AddUnsharedEntry x | Shared u -> failwithf "AddUnsharedEntry: incorrect table kind, u.Name = %s" u.Name
member t.GenericRowsOfTable = match t with Unshared u -> u.EntriesAsArray |> Array.map (fun x -> x.GenericRow) | Shared u -> u.EntriesAsArray |> Array.map (fun x -> x.GenericRow)
- member t.SetRowsOfSharedTable rows = match t with Shared u -> u.SetRowsOfTable (Array.map SharedRow rows) | Unshared u -> failwithf "SetRowsOfSharedTable: incorrect table kind, u.name = %s" u.name
+ member t.SetRowsOfSharedTable rows = match t with Shared u -> u.SetRowsOfTable (Array.map SharedRow rows) | Unshared u -> failwithf "SetRowsOfSharedTable: incorrect table kind, u.Name = %s" u.Name
member t.Count = match t with Unshared u -> u.Count | Shared u -> u.Count
@@ -652,6 +651,21 @@ type ILTokenMappings =
PropertyTokenMap: ILTypeDef list * ILTypeDef -> ILPropertyDef -> int32
EventTokenMap: ILTypeDef list * ILTypeDef -> ILEventDef -> int32 }
+[]
+/// Represents the length of each metadata heap emitted for the current module.
+type MetadataHeapSizes =
+ { StringHeapSize: int
+ UserStringHeapSize: int
+ BlobHeapSize: int
+ GuidHeapSize: int }
+
+[]
+/// Snapshot of the metadata state (heap sizes, table row counts, GUID stream offset) used for hot reload baselines.
+type MetadataSnapshot =
+ { HeapSizes: MetadataHeapSizes
+ TableRowCounts: int[]
+ GuidHeapStart: int }
+
let recordRequiredDataFixup (requiredDataFixups: ('T * 'U) list ref) (buf: ByteBuffer) pos lab =
requiredDataFixups.Value <- (pos, lab) :: requiredDataFixups.Value
// Write a special value in that we check later when applying the fixup
@@ -1115,7 +1129,7 @@ let FindMethodDefIdx cenv mdkey =
with :? KeyNotFoundException ->
let typeNameOfIdx i =
match
- (cenv.typeDefs.dict
+ (cenv.typeDefs.KeyValueSeq
|> Seq.fold (fun sofar kvp ->
let tkey2 = kvp.Key
let tidx2 = kvp.Value
@@ -1129,7 +1143,7 @@ let FindMethodDefIdx cenv mdkey =
let (TdKey (tenc, tname)) = typeNameOfIdx mdkey.TypeIdx
dprintn ("The local method '"+(String.concat "." (tenc@[tname]))+"'::'"+mdkey.Name+"' was referenced but not declared")
dprintn ("generic arity: "+string mdkey.GenericArity)
- cenv.methodDefIdxsByKey.dict |> Seq.iter (fun (KeyValue(mdkey2, _)) ->
+ cenv.methodDefIdxsByKey.KeyValueSeq |> Seq.iter (fun (KeyValue(mdkey2, _)) ->
if mdkey2.TypeIdx = mdkey.TypeIdx && mdkey.Name = mdkey2.Name then
let (TdKey (tenc2, tname2)) = typeNameOfIdx mdkey2.TypeIdx
dprintn ("A method in '"+(String.concat "." (tenc2@[tname2]))+"' had the right name but the wrong signature:")
@@ -2477,6 +2491,24 @@ let GenILMethodBody mname cenv env (il: ILMethodBody) =
localToken, (requiredStringFixups', methbuf.AsMemory().ToArray()), seqpoints, scopes
+type EncodedMethodBody =
+ { LocalSignatureToken: int
+ RequiredStringFixupsOffset: int
+ RequiredStringFixups: (int * int) list
+ Code: byte[]
+ SequencePoints: PdbDebugPoint[]
+ RootScope: PdbMethodScope option }
+
+let EncodeMethodBody cenv env mname ilmbody =
+ let localToken, ((offset, fixups), codeBytes), seqpoints, scope = GenILMethodBody mname cenv env ilmbody
+
+ { LocalSignatureToken = localToken
+ RequiredStringFixupsOffset = offset
+ RequiredStringFixups = fixups
+ Code = codeBytes
+ SequencePoints = seqpoints
+ RootScope = if cenv.generatePdb then Some scope else None }
+
// --------------------------------------------------------------------
// ILFieldDef --> FieldDef Row
// --------------------------------------------------------------------
@@ -2672,31 +2704,31 @@ let GenMethodDefAsRow cenv env midx (mdef: ILMethodDef) =
else
ilmbodyLazy.Value
let addr = cenv.nextCodeAddr
- let localToken, code, seqpoints, rootScope = GenILMethodBody mdef.Name cenv env ilmbody
+ let encodedBody = EncodeMethodBody cenv env mdef.Name ilmbody
// Now record the PDB record for this method - we write this out later.
if cenv.generatePdb then
cenv.pdbinfo.Add
- { MethToken=getUncodedToken TableNames.Method midx
- MethName=mdef.Name
- LocalSignatureToken=localToken
- Params= [| |] (* REVIEW *)
- RootScope = Some rootScope
+ { MethToken = getUncodedToken TableNames.Method midx
+ MethName = mdef.Name
+ LocalSignatureToken = encodedBody.LocalSignatureToken
+ Params = [| |] (* REVIEW *)
+ RootScope = encodedBody.RootScope
DebugRange =
match ilmbody.DebugRange with
| Some m when cenv.generatePdb ->
// table indexes are 1-based, document array indexes are 0-based
let doc = (cenv.documents.FindOrAddSharedEntry m.Document) - 1
- Some ({ Document=doc
- Line=m.Line
- Column=m.Column },
- { Document=doc
- Line=m.EndLine
- Column=m.EndColumn })
+ Some ({ Document = doc
+ Line = m.Line
+ Column = m.Column },
+ { Document = doc
+ Line = m.EndLine
+ Column = m.EndColumn })
| _ -> None
- DebugPoints=seqpoints }
- cenv.AddCode code
+ DebugPoints = encodedBody.SequencePoints }
+ cenv.AddCode ((encodedBody.RequiredStringFixupsOffset, encodedBody.RequiredStringFixups), encodedBody.Code)
addr
| MethodBody.Abstract
| MethodBody.PInvoke _
@@ -3275,7 +3307,10 @@ let writeILMetadataAndCode (
allGivenSources,
modul,
cilStartAddress,
- normalizeAssemblyRefs
+ normalizeAssemblyRefs,
+ // Hot reload baseline side channel: when false (the default compilation path) no
+ // MetadataSnapshot is materialized, so flag-off compiles pay no extra allocations.
+ collectMetadataSnapshot: bool
) =
// When we know the real RVAs of the data section we fixup the references for the FieldRVA table.
@@ -3679,7 +3714,22 @@ let writeILMetadataAndCode (
applyFixup32 code locInCode token
reportTime "Fixup Metadata"
- entryPointToken, code, codePadding, metadata, data, resources, requiredDataFixups.Value, pdbData, mappings, guidStart
+ // Hot reload baseline side channel: only materialize the snapshot when a consumer asked
+ // for one (--test:HotReloadDeltas in-memory emission); ordinary compiles skip it entirely.
+ let metadataSnapshotOpt =
+ if collectMetadataSnapshot then
+ Some
+ { HeapSizes =
+ { StringHeapSize = stringsStreamUnpaddedSize
+ UserStringHeapSize = userStringsStreamUnpaddedSize
+ BlobHeapSize = blobsStreamUnpaddedSize
+ GuidHeapSize = guidsStreamUnpaddedSize }
+ TableRowCounts = tables |> Seq.map (fun t -> t.Count) |> Seq.toArray
+ GuidHeapStart = guidStart }
+ else
+ None
+
+ entryPointToken, code, codePadding, metadata, data, resources, requiredDataFixups.Value, pdbData, mappings, guidStart, metadataSnapshotOpt
//---------------------------------------------------------------------
// PHYSICAL METADATA+BLOBS --> PHYSICAL PE FORMAT
@@ -3863,11 +3913,21 @@ type options =
referenceAssemblyAttribOpt: ILAttribute option
referenceAssemblySignatureHash : int option
pathMap: PathMap
- /// Per-method EnC CustomDebugInformation rows for the portable PDB writer, keyed by
- /// IL method name. Empty for ordinary compiles, so flag-off output stays byte-identical.
+ // Hot reload baseline side channel: module-level CustomDebugInformation rows for
+ // F#-owned records in the portable PDB. Empty unless a gated hot reload capture
+ // compile needs to persist extra deterministic state.
+ moduleCustomDebugInfoRows: PdbModuleCustomDebugInfo list
+ // Hot reload baseline side channel: per-method EnC CustomDebugInformation rows for
+ // the portable PDB writer, keyed by IL method name. Empty unless the compilation
+ // runs with --test:HotReloadDeltas (flag-off output stays byte-identical).
methodCustomDebugInfoRows: Map }
-let writeBinaryAux (stream: Stream, options: options, modul, normalizeAssemblyRefs) =
+///
+/// Core IL writer that emits the PE image and, when is
+/// present, invokes it with the captured metadata snapshot once the metadata streams have been
+/// finalized. When the sink is None (ordinary compilation) no snapshot is constructed.
+///
+let writeBinaryAuxWithSnapshotSink (stream: Stream, options: options, modul, normalizeAssemblyRefs) (metadataSnapshotSink: (MetadataSnapshot -> unit) option) =
// Store the public key from the signer into the manifest. This means it will be written
// to the binary and also acts as an indicator to leave space for delay sign
@@ -3980,22 +4040,28 @@ let writeBinaryAux (stream: Stream, options: options, modul, normalizeAssemblyRe
| Some v -> v
| None -> failwith "Expected mscorlib to have a version number"
- let entryPointToken, code, codePadding, metadata, data, resources, requiredDataFixups, pdbData, mappings, guidStart =
+ let entryPointToken, code, codePadding, metadata, data, resources, requiredDataFixups, pdbData, mappings, guidStart, metadataSnapshotOpt =
writeILMetadataAndCode (
options.pdbfile.IsSome,
desiredMetadataVersion,
ilg,
options.emitTailcalls,
- options.deterministic,
+ options.deterministic,
options.referenceAssemblyOnly,
options.referenceAssemblyAttribOpt,
options.allGivenSources,
modul,
next,
- normalizeAssemblyRefs
+ normalizeAssemblyRefs,
+ metadataSnapshotSink.IsSome
)
reportTime "Generated IL and metadata"
+
+ match metadataSnapshotSink, metadataSnapshotOpt with
+ | Some sink, Some metadataSnapshot -> sink metadataSnapshot
+ | _ -> ()
+
let _codeChunk, next = chunk code.Length next
let _codePaddingChunk, next = chunk codePadding.Length next
@@ -4028,7 +4094,15 @@ let writeBinaryAux (stream: Stream, options: options, modul, normalizeAssemblyRe
match options.pdbfile, options.portablePDB with
| Some _, true ->
let pdbInfo =
- generatePortablePdb options.embedAllSource options.embedSourceList options.sourceLink options.checksumAlgorithm pdbData options.pathMap options.methodCustomDebugInfoRows
+ generatePortablePdb
+ options.embedAllSource
+ options.embedSourceList
+ options.sourceLink
+ options.checksumAlgorithm
+ pdbData
+ options.pathMap
+ options.moduleCustomDebugInfoRows
+ options.methodCustomDebugInfoRows
if options.embeddedPDB then
let uncompressedLength, contentId, stream, algorithmName, checkSum = pdbInfo
@@ -4570,6 +4644,9 @@ let writeBinaryAux (stream: Stream, options: options, modul, normalizeAssemblyRe
reportTime "Writing Image"
pdbData, pdbInfoOpt, debugDirectoryChunk, debugDataChunk, debugChecksumPdbChunk, debugEmbeddedPdbChunk, debugDeterministicPdbChunk, textV2P, mappings
+let writeBinaryAux (stream: Stream, options: options, modul, normalizeAssemblyRefs) =
+ writeBinaryAuxWithSnapshotSink (stream, options, modul, normalizeAssemblyRefs) None
+
let writeBinaryFiles (options: options, modul, normalizeAssemblyRefs) =
let stream =
@@ -4615,12 +4692,20 @@ let writeBinaryFiles (options: options, modul, normalizeAssemblyRefs) =
mappings
-let writeBinaryInMemory (options: options, modul, normalizeAssemblyRefs) =
+let writeBinaryInMemoryWithArtifacts (options: options, modul, normalizeAssemblyRefs) =
let stream = new MemoryStream()
let options = { options with referenceAssemblyOnly = false; referenceAssemblyAttribOpt = None; referenceAssemblySignatureHash = None }
- let pdbData, pdbInfoOpt, debugDirectoryChunk, debugDataChunk, debugChecksumPdbChunk, debugEmbeddedPdbChunk, debugDeterministicPdbChunk, textV2P, _mappings =
- writeBinaryAux(stream, options, modul, normalizeAssemblyRefs)
+ // Capture exactly one metadata snapshot for the emitted module so callers can persist baseline information.
+ let metadataSnapshotRef = ref None
+ let capture snapshot = metadataSnapshotRef := Some snapshot
+ let pdbData, pdbInfoOpt, debugDirectoryChunk, debugDataChunk, debugChecksumPdbChunk, debugEmbeddedPdbChunk, debugDeterministicPdbChunk, textV2P, mappings =
+ writeBinaryAuxWithSnapshotSink (stream, options, modul, normalizeAssemblyRefs) (Some capture)
+
+ let metadataSnapshot =
+ match !metadataSnapshotRef with
+ | Some snapshot -> snapshot
+ | None -> failwith "Metadata snapshot not captured"
let reopenOutput () =
stream.Seek(0, SeekOrigin.Begin) |> ignore
@@ -4646,12 +4731,15 @@ let writeBinaryInMemory (options: options, modul, normalizeAssemblyRefs) =
stream.Close()
- stream.ToArray(), pdbBytes
-
+ stream.ToArray(), pdbBytes, mappings, metadataSnapshot
let WriteILBinaryFile (options: options, inputModule, normalizeAssemblyRefs) =
writeBinaryFiles (options, inputModule, normalizeAssemblyRefs)
|> ignore
+let WriteILBinaryInMemoryWithArtifacts (options: options, inputModule: ILModuleDef, normalizeAssemblyRefs) =
+ writeBinaryInMemoryWithArtifacts (options, inputModule, normalizeAssemblyRefs)
+
let WriteILBinaryInMemory (options: options, inputModule: ILModuleDef, normalizeAssemblyRefs) =
- writeBinaryInMemory (options, inputModule, normalizeAssemblyRefs)
+ let assemblyBytes, pdbBytes, _, _ = writeBinaryInMemoryWithArtifacts (options, inputModule, normalizeAssemblyRefs)
+ assemblyBytes, pdbBytes
diff --git a/src/Compiler/AbstractIL/ilwrite.fsi b/src/Compiler/AbstractIL/ilwrite.fsi
index edb46b98a31..7af65c95c41 100644
--- a/src/Compiler/AbstractIL/ilwrite.fsi
+++ b/src/Compiler/AbstractIL/ilwrite.fsi
@@ -28,11 +28,47 @@ type options =
referenceAssemblyAttribOpt: ILAttribute option
referenceAssemblySignatureHash: int option
pathMap: PathMap
- /// Per-method EnC CustomDebugInformation rows for the portable PDB writer, keyed by
- /// IL method name. Empty for ordinary compiles, so flag-off output stays byte-identical.
+ /// Hot reload baseline side channel: module-level CustomDebugInformation rows for
+ /// F#-owned records in the portable PDB. Empty unless a gated hot reload capture
+ /// compile needs to persist extra deterministic state.
+ moduleCustomDebugInfoRows: PdbModuleCustomDebugInfo list
+ /// Hot reload baseline side channel: per-method EnC CustomDebugInformation rows for
+ /// the portable PDB writer, keyed by IL method name. Empty unless the compilation
+ /// runs with --test:HotReloadDeltas (flag-off output stays byte-identical).
methodCustomDebugInfoRows: Map
}
+///
+/// Captures the various metadata token mapping functions produced by the IL writer.
+///
+[]
+type ILTokenMappings =
+ { TypeDefTokenMap: ILTypeDef list * ILTypeDef -> int32
+ FieldDefTokenMap: ILTypeDef list * ILTypeDef -> ILFieldDef -> int32
+ MethodDefTokenMap: ILTypeDef list * ILTypeDef -> ILMethodDef -> int32
+ PropertyTokenMap: ILTypeDef list * ILTypeDef -> ILPropertyDef -> int32
+ EventTokenMap: ILTypeDef list * ILTypeDef -> ILEventDef -> int32 }
+
+///
+/// Records the uncompressed heap sizes produced during metadata emission so that later delta passes
+/// can reason about stream growth.
+///
+[]
+type MetadataHeapSizes =
+ { StringHeapSize: int
+ UserStringHeapSize: int
+ BlobHeapSize: int
+ GuidHeapSize: int }
+
+///
+/// Snapshot of the emitted metadata state that is required to seed hot reload baseline calculations.
+///
+[]
+type MetadataSnapshot =
+ { HeapSizes: MetadataHeapSizes
+ TableRowCounts: int[]
+ GuidHeapStart: int }
+
/// Computes the trailing byte for a user string blob per ECMA-335 II.24.2.4.
/// Returns 1 if any character needs special handling, 0 otherwise.
val markerForUnicodeBytes: b: byte[] -> int
@@ -43,3 +79,8 @@ val WriteILBinaryFile: options: options * inputModule: ILModuleDef * (ILAssembly
/// Write a binary to an array of bytes suitable for dynamic loading.
val WriteILBinaryInMemory:
options: options * inputModule: ILModuleDef * (ILAssemblyRef -> ILAssemblyRef) -> byte[] * byte[] option
+
+/// Write a binary to an array of bytes and capture token and metadata artifacts.
+val WriteILBinaryInMemoryWithArtifacts:
+ options: options * inputModule: ILModuleDef * (ILAssemblyRef -> ILAssemblyRef) ->
+ byte[] * byte[] option * ILTokenMappings * MetadataSnapshot
diff --git a/src/Compiler/AbstractIL/ilwritepdb.fs b/src/Compiler/AbstractIL/ilwritepdb.fs
index 70f88b471d7..b3234e0c25c 100644
--- a/src/Compiler/AbstractIL/ilwritepdb.fs
+++ b/src/Compiler/AbstractIL/ilwritepdb.fs
@@ -122,6 +122,10 @@ type PdbMethodData =
/// definition row in the portable PDB.
type PdbMethodCustomDebugInfo = { KindGuid: Guid; Blob: byte[] }
+/// A pre-serialized CustomDebugInformation row (kind GUID + blob) to attach to the
+/// module definition row in the portable PDB.
+type PdbModuleCustomDebugInfo = { KindGuid: Guid; Blob: byte[] }
+
module SequencePoint =
let orderBySource sp1 sp2 =
let c1 = compare sp1.Document sp2.Document
@@ -167,10 +171,28 @@ type HashAlgorithm =
| Sha1
| Sha256
-// Document checksum algorithms
+// ============================================================================
+// Well-known PDB GUIDs (Portable PDB metadata)
+// ============================================================================
+
+/// Document checksum algorithm: SHA-1 (Portable PDB spec)
let guidSha1 = Guid("ff1816ec-aa5e-4d10-87f7-6f4963833460")
+
+/// Document checksum algorithm: SHA-256 (Portable PDB spec)
let guidSha2 = Guid("8829d00f-11b8-4213-878b-770e8597ac16")
+/// F# language GUID for Portable PDB Document.Language field
+let corSymLanguageTypeFSharp =
+ Guid(0xAB4F38C9u, 0xB6E6us, 0x43baus, 0xBEuy, 0x3Buy, 0x58uy, 0x08uy, 0x0Buy, 0x2Cuy, 0xCCuy, 0xE3uy)
+
+/// Embedded source custom debug information GUID
+let embeddedSourceGuid =
+ Guid(0x0e8a571bu, 0x6926us, 0x466eus, 0xb4uy, 0xaduy, 0x8auy, 0xb0uy, 0x46uy, 0x11uy, 0xf5uy, 0xfeuy)
+
+/// Source link custom debug information GUID
+let sourceLinkGuid =
+ Guid(0xcc110556u, 0xa091us, 0x4d38us, 0x9fuy, 0xecuy, 0x25uy, 0xabuy, 0x9auy, 0x35uy, 0x1auy, 0x6auy)
+
let checkSum (url: string) (checksumAlgorithm: HashAlgorithm) =
try
use file = FileSystem.OpenFileForReadShim(url)
@@ -348,6 +370,7 @@ type PortablePdbGenerator
checksumAlgorithm,
info: PdbData,
pathMap: PathMap,
+ moduleCustomDebugInfoRows: PdbModuleCustomDebugInfo list,
methodCustomDebugInfoRows: Map
) =
@@ -381,14 +404,9 @@ type PortablePdbGenerator
metadata.GetOrAddBlob writer
- let corSymLanguageTypeId =
- Guid(0xAB4F38C9u, 0xB6E6us, 0x43baus, 0xBEuy, 0x3Buy, 0x58uy, 0x08uy, 0x0Buy, 0x2Cuy, 0xCCuy, 0xE3uy)
-
- let embeddedSourceId =
- Guid(0x0e8a571bu, 0x6926us, 0x466eus, 0xb4uy, 0xaduy, 0x8auy, 0xb0uy, 0x46uy, 0x11uy, 0xf5uy, 0xfeuy)
-
- let sourceLinkId =
- Guid(0xcc110556u, 0xa091us, 0x4d38us, 0x9fuy, 0xecuy, 0x25uy, 0xabuy, 0x9auy, 0x35uy, 0x1auy, 0x6auy)
+ let corSymLanguageTypeId = corSymLanguageTypeFSharp
+ let embeddedSourceId = embeddedSourceGuid
+ let sourceLinkId = sourceLinkGuid
///
/// The maximum number of bytes in to write out uncompressed.
@@ -484,6 +502,14 @@ type PortablePdbGenerator
)
|> ignore
+ for cdiRow in moduleCustomDebugInfoRows |> List.sortBy (fun row -> row.KindGuid) do
+ metadata.AddCustomDebugInformation(
+ ModuleDefinitionHandle.op_Implicit EntityHandle.ModuleDefinition,
+ metadata.GetOrAddGuid cdiRow.KindGuid,
+ metadata.GetOrAddBlob cdiRow.Blob
+ )
+ |> ignore
+
index
let mutable lastLocalVariableHandle = Unchecked.defaultof
@@ -881,10 +907,20 @@ let generatePortablePdb
checksumAlgorithm
(info: PdbData)
(pathMap: PathMap)
+ (moduleCustomDebugInfoRows: PdbModuleCustomDebugInfo list)
(methodCustomDebugInfoRows: Map)
=
let generator =
- PortablePdbGenerator(embedAllSource, embedSourceList, sourceLink, checksumAlgorithm, info, pathMap, methodCustomDebugInfoRows)
+ PortablePdbGenerator(
+ embedAllSource,
+ embedSourceList,
+ sourceLink,
+ checksumAlgorithm,
+ info,
+ pathMap,
+ moduleCustomDebugInfoRows,
+ methodCustomDebugInfoRows
+ )
generator.Emit()
diff --git a/src/Compiler/AbstractIL/ilwritepdb.fsi b/src/Compiler/AbstractIL/ilwritepdb.fsi
index 09d380e44cc..508b892b813 100644
--- a/src/Compiler/AbstractIL/ilwritepdb.fsi
+++ b/src/Compiler/AbstractIL/ilwritepdb.fsi
@@ -68,11 +68,17 @@ type PdbMethodData =
DebugPoints: PdbDebugPoint[] }
/// A pre-serialized CustomDebugInformation row to attach to a method definition row in
-/// the portable PDB (kind GUID + blob). Supplied by the compiler as a side channel keyed
-/// by IL method name. The writer attaches the rows only when the name identifies exactly
-/// one method row (fail closed on ambiguity).
+/// the portable PDB (kind GUID + blob). Supplied by the compiler as a side channel for
+/// hot reload baseline emission (--test:HotReloadDeltas): EnC lambda/closure map blobs
+/// computed from the typed tree, keyed by IL method name. The writer attaches the rows
+/// only when the name identifies exactly one method row (fail closed on ambiguity).
type PdbMethodCustomDebugInfo = { KindGuid: System.Guid; Blob: byte[] }
+/// A pre-serialized CustomDebugInformation row to attach to the module definition row
+/// in the portable PDB (kind GUID + blob). Supplied by hot reload for F#-owned
+/// deterministic baseline records.
+type PdbModuleCustomDebugInfo = { KindGuid: System.Guid; Blob: byte[] }
+
[]
type PdbData =
{
@@ -115,6 +121,7 @@ val generatePortablePdb:
checksumAlgorithm: HashAlgorithm ->
info: PdbData ->
pathMap: PathMap ->
+ moduleCustomDebugInfoRows: PdbModuleCustomDebugInfo list ->
methodCustomDebugInfoRows: Map ->
int64 * BlobContentId * MemoryStream * string * byte[]
diff --git a/src/Compiler/CodeGen/EncMethodDebugInformation.fs b/src/Compiler/CodeGen/EncMethodDebugInformation.fs
new file mode 100644
index 00000000000..c67be11a632
--- /dev/null
+++ b/src/Compiler/CodeGen/EncMethodDebugInformation.fs
@@ -0,0 +1,970 @@
+/// Edit-and-Continue method debug information blobs for hot reload.
+///
+/// This module replicates, byte for byte, the three Portable-PDB CustomDebugInformation
+/// blob formats Roslyn persists per method to support Edit and Continue
+/// (roslyn/src/Compilers/Core/Portable/Emit/EditAndContinueMethodDebugInformation.cs):
+///
+/// - EnC Local Slot Map (kind 755F52A8-91C5-45BE-B4B8-209571E552BD)
+/// - EnC Lambda and Closure Map (kind A643004C-0240-496F-A783-30D64F4979DE)
+/// - EnC State Machine State Map (kind 8B78CD68-2EDE-420B-980B-E15884B8AAA3)
+///
+/// (GUIDs: roslyn/src/Dependencies/CodeAnalysis.Debugging/PortableCustomDebugInfoKinds.cs.)
+///
+/// All multi-byte integers use the ECMA-335 compressed unsigned/signed encodings via
+/// System.Reflection.Metadata's BlobBuilder.WriteCompressedInteger /
+/// WriteCompressedSignedInteger and BlobReader.ReadCompressedInteger /
+/// ReadCompressedSignedInteger, exactly as Roslyn writes/reads them.
+///
+/// F# semantics of the "syntax offset" slots: Roslyn stores the syntax offset of the
+/// lambda/closure/state-machine-suspension syntax node. The F# typed-tree diff has no
+/// syntax map; instead these integer slots carry OCCURRENCE KEYS — a deterministic
+/// int packed from the occurrence ordinal chain of the lambda occurrence model
+/// (TypedTreeDiff.LambdaOccurrenceId). See tryEncodeOccurrenceKey/decodeOccurrenceKey.
+/// The blob format is identical either way, so mdv/Roslyn tooling can still decode our
+/// maps; only the *meaning* of the integers is F#-specific (debugger-interop
+/// caveat documented in docs/hot-reload-closure-mapping.md).
+module internal FSharp.Compiler.EncMethodDebugInformation
+
+#nowarn "9" // NativePtr: BlobReader only exposes a byte*-based constructor
+
+open System
+open System.Collections.Generic
+open System.Collections.Immutable
+open System.IO
+open System.Reflection.Metadata
+open System.Reflection.Metadata.Ecma335
+open System.Runtime.InteropServices
+open System.Text
+open Microsoft.FSharp.NativeInterop
+
+open FSharp.Compiler.AbstractIL.ILPdbWriter
+open FSharp.Compiler.TcGlobals
+open FSharp.Compiler.TypedTree
+open FSharp.Compiler.TypedTreeDiff
+
+/// Portable-PDB CustomDebugInformation kind GUIDs for the EnC blobs, copied verbatim
+/// from roslyn/src/Dependencies/CodeAnalysis.Debugging/PortableCustomDebugInfoKinds.cs.
+[]
+module PortableCustomDebugInfoKinds =
+
+ /// EnC Local Slot Map CDI kind.
+ let encLocalSlotMap = Guid("755F52A8-91C5-45BE-B4B8-209571E552BD")
+
+ /// EnC Lambda and Closure Map CDI kind.
+ let encLambdaAndClosureMap = Guid("A643004C-0240-496F-A783-30D64F4979DE")
+
+ /// EnC State Machine State Map CDI kind.
+ let encStateMachineStateMap = Guid("8B78CD68-2EDE-420B-980B-E15884B8AAA3")
+
+ /// F#-owned hot reload synthesized-name snapshot CDI kind. The blob records
+ /// FSharpSynthesizedTypeMaps.Snapshot bucket arrays in allocation-slot order.
+ let fsharpSynthesizedNameSnapshot = Guid("49DDB47E-9C74-46EC-8626-0350676571EB")
+
+/// Closure ordinal of a lambda that is lowered to a static (non-capturing) method.
+/// Mirrors Roslyn's LambdaDebugInfo.StaticClosureOrdinal.
+[]
+let StaticClosureOrdinal = -1
+
+/// Closure ordinal of a lambda closed over the 'this' pointer only.
+/// Mirrors Roslyn's LambdaDebugInfo.ThisOnlyClosureOrdinal.
+[]
+let ThisOnlyClosureOrdinal = -2
+
+/// Smallest valid closure ordinal. Mirrors Roslyn's LambdaDebugInfo.MinClosureOrdinal.
+[]
+let MinClosureOrdinal = ThisOnlyClosureOrdinal
+
+/// Method ordinal of a method that has no lambda map (an empty blob decodes to this).
+/// Mirrors Roslyn's DebugId.UndefinedOrdinal.
+[]
+let UndefinedMethodOrdinal = -1
+
+/// Marker byte introducing the (optional) negative syntax-offset baseline in the
+/// local-slot-map blob. Mirrors Roslyn's SyntaxOffsetBaseline = 0xFF.
+[]
+let private SyntaxOffsetBaselineMarker = 0xFFuy
+
+/// Largest synthesized-local kind serializable in the slot map: the kind is stored as
+/// (kind + 1) in bits 0-6 of the leading byte (bit 7 flags a trailing ordinal), and
+/// Roslyn's reader recovers it with mask 0x3F, so only kinds 0..0x3E round-trip.
+[]
+let MaxSerializableLocalKind = 0x3E
+
+/// One slot in the EnC Local Slot Map: the local variable layout of a method body,
+/// recorded so a later generation can map its locals onto the same slot indices.
+[]
+type EncLocalSlotInfo =
+ /// A short-lived lowering temp: serialized as the single byte 0x00, carrying no
+ /// identity (a later generation never reuses it).
+ | Temp
+
+ /// A long-lived synthesized local.
+ /// kind: synthesized-local kind (Roslyn SynthesizedLocalKind value, 0..MaxSerializableLocalKind;
+ /// 0 = user-defined local).
+ /// syntaxOffset: in F#, the occurrence key of the declaring occurrence
+ /// (Roslyn: syntax offset of the local's declarator).
+ /// ordinal: zero-based disambiguator among slots sharing the same kind and offset (>= 0).
+ | Slot of kind: int * syntaxOffset: int * ordinal: int
+
+/// One closure scope in the EnC Lambda and Closure Map. The closure's ordinal is its
+/// index in EncMethodDebugInformation.Closures; lambdas reference closures by that index.
+/// SyntaxOffset: in F#, the occurrence key of the closure's occurrence.
+type EncClosureInfo =
+ {
+ /// Occurrence key (Roslyn: syntax offset of the scope owning the closure).
+ SyntaxOffset: int
+ }
+
+/// One lambda in the EnC Lambda and Closure Map.
+type EncLambdaInfo =
+ {
+ /// Occurrence key (Roslyn: syntax offset of the lambda body).
+ SyntaxOffset: int
+ /// Index into EncMethodDebugInformation.Closures of the closure holding the
+ /// lambda's captures, or StaticClosureOrdinal / ThisOnlyClosureOrdinal.
+ ClosureOrdinal: int
+ }
+
+/// One suspension point in the EnC State Machine State Map.
+type EncStateMachineStateInfo =
+ {
+ /// State machine state number assigned to the suspension point (may be negative:
+ /// Roslyn uses negative numbers for increasing-iteration finalize states).
+ StateNumber: int
+ /// Occurrence key (Roslyn: syntax offset of the await/yield syntax node).
+ SyntaxOffset: int
+ }
+
+/// Debugging information associated with a method, persisted by the compiler in the
+/// Portable PDB to support Edit and Continue. Mirrors Roslyn's
+/// EditAndContinueMethodDebugInformation.
+type EncMethodDebugInformation =
+ {
+ /// Ordinal of the method within its generation (>= -1; UndefinedMethodOrdinal when absent).
+ MethodOrdinal: int
+ /// Local slot layout, in slot-index order (EnC Local Slot Map).
+ LocalSlots: EncLocalSlotInfo list
+ /// Closure scopes, in ordinal order (EnC Lambda and Closure Map).
+ Closures: EncClosureInfo list
+ /// Lambdas, in ordinal order (EnC Lambda and Closure Map).
+ Lambdas: EncLambdaInfo list
+ /// State machine suspension points (EnC State Machine State Map).
+ StateMachineStates: EncStateMachineStateInfo list
+ }
+
+ /// An empty map (no slots, lambdas, closures or states; undefined method ordinal).
+ static member Empty =
+ {
+ MethodOrdinal = UndefinedMethodOrdinal
+ LocalSlots = []
+ Closures = []
+ Lambdas = []
+ StateMachineStates = []
+ }
+
+// ---------------------------------------------------------------------------
+// Occurrence-key packing
+// ---------------------------------------------------------------------------
+
+/// Maximum encodable occurrence ordinal: each chain segment is 16 bits.
+[]
+let private MaxOccurrenceSegment = 0xFFFF
+
+/// Compressed unsigned integers must lie in [0, 0x1FFFFFFF); after baseline adjustment
+/// the serialized value is (key - baseline) with baseline <= -1, so keys must stay
+/// strictly below 0x1FFFFFFF - 1 to be writable. Cap at 29 bits minus the adjustment.
+[]
+let private MaxOccurrenceKey = 0x1FFFFFFD
+
+/// Packs an occurrence ordinal chain (root-first enclosing-occurrence ordinals,
+/// ending with the occurrence's own ordinal) into the deterministic int carried in the
+/// "syntax offset" blob slots. Packing: 16-bit segments, least-significant segment =
+/// the occurrence's own ordinal; an enclosing ordinal p is stored as (p + 1) shifted
+/// left 16 so that depth-1 keys (< 0x10000) and depth-2 keys (>= 0x10000) never collide.
+/// Fails closed (None) past the limits: chains deeper than 2, ordinals > 0xFFFF,
+/// or keys exceeding the compressed-integer budget — callers must then treat the
+/// occurrence as unmappable (rude edit), never truncate.
+let tryEncodeOccurrenceKey (ordinalChain: int list) : int option =
+ match ordinalChain with
+ | [ ordinal ] when ordinal >= 0 && ordinal <= MaxOccurrenceSegment -> Some ordinal
+ | [ parent; ordinal ] when
+ parent >= 0
+ && ordinal >= 0
+ && ordinal <= MaxOccurrenceSegment
+ && parent < MaxOccurrenceSegment
+ ->
+ // Pack in int64: a large parent would wrap negative in int32 and otherwise pass
+ // the upper-bound check, turning an unrepresentable occurrence into a corrupt key.
+ let key = ((int64 parent + 1L) <<< 16) ||| int64 ordinal
+
+ if key <= int64 MaxOccurrenceKey then
+ Some(int key)
+ else
+ None
+ | _ -> None
+
+/// Unpacks an occurrence key produced by tryEncodeOccurrenceKey back into its
+/// root-first ordinal chain.
+let decodeOccurrenceKey (key: int) : int list =
+ if key < 0 then
+ invalidArg (nameof key) $"occurrence key must be non-negative, got %d{key}"
+ elif key <= MaxOccurrenceSegment then
+ [ key ]
+ else
+ [ (key >>> 16) - 1; key &&& MaxOccurrenceSegment ]
+
+// ---------------------------------------------------------------------------
+// Blob helpers
+// ---------------------------------------------------------------------------
+
+let private invalidData (blobName: string) (offset: int) =
+ raise (InvalidDataException $"invalid EnC %s{blobName} blob: unexpected data at offset %d{offset}")
+
+// Absent CDI rows arrive as null at runtime even though the parameter is non-null in the
+// nullness model, so guard with box (FS3261-safe) rather than dropping the check.
+let private isEmpty (blob: byte[]) = isNull (box blob) || blob.Length = 0
+
+// ---------------------------------------------------------------------------
+// F# hot reload module CDI: synthesized-name allocation snapshot
+// Format:
+// compressed(version = 1), compressed(bucket count),
+// then buckets sorted by key for deterministic PDB bytes:
+// string key, compressed(name count), string name in allocation-slot order.
+// Strings are compressed(byte length) followed by UTF-8 bytes.
+// ---------------------------------------------------------------------------
+
+[]
+let private SynthesizedNameSnapshotBlobVersion = 1
+
+let private writeUtf8String (builder: BlobBuilder) (value: string) =
+ if isNull (box value) then
+ invalidArg (nameof value) "snapshot strings must be non-null"
+
+ let bytes = Encoding.UTF8.GetBytes value
+ builder.WriteCompressedInteger bytes.Length
+ builder.WriteBytes bytes
+
+let private readUtf8String (blobName: string) (reader: byref) =
+ let length = reader.ReadCompressedInteger()
+
+ if length < 0 || length > reader.RemainingBytes then
+ invalidData blobName reader.Offset
+
+ let bytes = reader.ReadBytes length
+ Encoding.UTF8.GetString(bytes, 0, bytes.Length)
+
+let private materializeSynthesizedNameSnapshot (snapshot: seq) =
+ snapshot
+ |> Seq.map (fun struct (key, names) ->
+ if isNull (box key) then
+ invalidArg (nameof snapshot) "snapshot keys must be non-null"
+
+ if isNull (box names) then
+ invalidArg (nameof snapshot) $"snapshot bucket '{key}' must be non-null"
+
+ key, Array.copy names)
+ |> Seq.sortBy fst
+ |> Seq.toArray
+
+/// Serializes an allocation-ordered synthesized-name snapshot into the F#-owned module
+/// CDI blob. An empty snapshot returns an empty blob so no CDI row needs to be emitted.
+let serializeSynthesizedNameSnapshot (snapshot: seq) : byte[] =
+ let buckets = materializeSynthesizedNameSnapshot snapshot
+
+ if buckets.Length = 0 then
+ Array.empty
+ else
+ let builder = BlobBuilder()
+ builder.WriteCompressedInteger SynthesizedNameSnapshotBlobVersion
+ builder.WriteCompressedInteger buckets.Length
+
+ for key, names in buckets do
+ writeUtf8String builder key
+ builder.WriteCompressedInteger names.Length
+
+ for name in names do
+ writeUtf8String builder name
+
+ builder.ToArray()
+
+/// Deserializes the F#-owned synthesized-name snapshot CDI blob. Bucket order in the
+/// blob is deterministic only; each bucket array is returned exactly in recorded slot order.
+let deserializeSynthesizedNameSnapshot (blob: byte[]) : Map =
+ if isEmpty blob then
+ Map.empty
+ else
+ let handle = GCHandle.Alloc(blob, GCHandleType.Pinned)
+
+ try
+ let mutable reader =
+ BlobReader(NativePtr.ofNativeInt (handle.AddrOfPinnedObject()), blob.Length)
+
+ try
+ let version = reader.ReadCompressedInteger()
+
+ if version <> SynthesizedNameSnapshotBlobVersion then
+ invalidData "synthesized name snapshot" reader.Offset
+
+ let bucketCount = reader.ReadCompressedInteger()
+
+ if bucketCount <= 0 || bucketCount > reader.RemainingBytes / 2 then
+ invalidData "synthesized name snapshot" reader.Offset
+
+ let buckets = ResizeArray()
+
+ for _ in 1..bucketCount do
+ let key = readUtf8String "synthesized name snapshot" &reader
+ let nameCount = reader.ReadCompressedInteger()
+
+ // Every serialized name consumes at least one byte for its UTF-8 length,
+ // so bound allocation by the remaining payload before creating the array.
+ if nameCount < 0 || nameCount > reader.RemainingBytes then
+ invalidData "synthesized name snapshot" reader.Offset
+
+ let names = Array.zeroCreate nameCount
+
+ for i in 0 .. nameCount - 1 do
+ names[i] <- readUtf8String "synthesized name snapshot" &reader
+
+ buckets.Add(key, names)
+
+ if reader.RemainingBytes <> 0 then
+ invalidData "synthesized name snapshot" reader.Offset
+
+ buckets |> Seq.map id |> Map.ofSeq
+ with :? BadImageFormatException ->
+ invalidData "synthesized name snapshot" reader.Offset
+ finally
+ handle.Free()
+
+/// Creates the module-level CustomDebugInformation row for the allocation-ordered
+/// synthesized-name snapshot. Empty snapshots emit no row.
+let computeSynthesizedNameSnapshotCustomDebugInfoRows (snapshot: seq) : PdbModuleCustomDebugInfo list =
+
+ let blob = serializeSynthesizedNameSnapshot snapshot
+
+ if blob.Length = 0 then
+ []
+ else
+ [
+ {
+ KindGuid = PortableCustomDebugInfoKinds.fsharpSynthesizedNameSnapshot
+ Blob = blob
+ }
+ ]
+
+// ---------------------------------------------------------------------------
+// EnC Local Slot Map
+// Format (EditAndContinueMethodDebugInformation.cs, SerializeLocalSlots lines 145-191,
+// UncompressSlotMap lines 92-143): optional baseline record [0xFF, compressed(-baseline)],
+// then one record per slot: 0x00 for a temp, otherwise a leading byte with bits 0-6 =
+// kind + 1 and bit 7 = has-ordinal flag, followed by compressed(syntaxOffset - baseline)
+// and, when flagged, compressed(ordinal).
+// ---------------------------------------------------------------------------
+
+/// Serializes the EnC Local Slot Map blob for 'info', byte-for-byte as Roslyn's
+/// SerializeLocalSlots. Returns the empty array when there are no slots (no CDI row
+/// should be emitted then).
+let serializeLocalSlots (info: EncMethodDebugInformation) : byte[] =
+ match info.LocalSlots with
+ | [] -> Array.empty
+ | slots ->
+ let builder = BlobBuilder()
+
+ // The baseline is the most negative syntax offset, or -1 when none is negative
+ // (Roslyn lines 147-160). Offsets are stored relative to it so the common
+ // all-non-negative case costs no baseline record.
+ let syntaxOffsetBaseline =
+ (-1, slots)
+ ||> List.fold (fun acc slot ->
+ match slot with
+ | EncLocalSlotInfo.Temp -> acc
+ | EncLocalSlotInfo.Slot(_, syntaxOffset, _) -> min acc syntaxOffset)
+
+ if syntaxOffsetBaseline <> -1 then
+ builder.WriteByte SyntaxOffsetBaselineMarker
+ builder.WriteCompressedInteger(-syntaxOffsetBaseline)
+
+ for slot in slots do
+ match slot with
+ | EncLocalSlotInfo.Temp -> builder.WriteByte 0uy
+ | EncLocalSlotInfo.Slot(kind, syntaxOffset, ordinal) ->
+ if kind < 0 || kind > MaxSerializableLocalKind then
+ invalidArg (nameof info) $"local slot kind %d{kind} is outside the serializable range 0..%d{MaxSerializableLocalKind}"
+
+ if ordinal < 0 then
+ invalidArg (nameof info) $"local slot ordinal must be non-negative, got %d{ordinal}"
+
+ let hasOrdinal = ordinal > 0
+ let b = byte (kind + 1) ||| (if hasOrdinal then 0x80uy else 0uy)
+ builder.WriteByte b
+ builder.WriteCompressedInteger(syntaxOffset - syntaxOffsetBaseline)
+
+ if hasOrdinal then
+ builder.WriteCompressedInteger ordinal
+
+ builder.ToArray()
+
+/// Deserializes an EnC Local Slot Map blob, byte-for-byte as Roslyn's UncompressSlotMap.
+/// An empty (or null) blob yields no slots.
+let deserializeLocalSlots (blob: byte[]) : EncLocalSlotInfo list =
+ if isEmpty blob then
+ []
+ else
+ let handle = GCHandle.Alloc(blob, GCHandleType.Pinned)
+
+ try
+ let mutable reader =
+ BlobReader(NativePtr.ofNativeInt (handle.AddrOfPinnedObject()), blob.Length)
+
+ let slots = ResizeArray()
+ let mutable syntaxOffsetBaseline = -1
+
+ try
+ while reader.RemainingBytes > 0 do
+ let b = reader.ReadByte()
+
+ if b = SyntaxOffsetBaselineMarker then
+ syntaxOffsetBaseline <- -reader.ReadCompressedInteger()
+ elif b = 0uy then
+ slots.Add EncLocalSlotInfo.Temp
+ else
+ // Roslyn recovers the kind with mask 0x3F (line 126); bit 7 flags
+ // a trailing ordinal, bit 6 is unused by the writer.
+ let kind = int (b &&& 0x3Fuy) - 1
+ let hasOrdinal = b &&& 0x80uy <> 0uy
+ let syntaxOffset = reader.ReadCompressedInteger() + syntaxOffsetBaseline
+ let ordinal = if hasOrdinal then reader.ReadCompressedInteger() else 0
+ slots.Add(EncLocalSlotInfo.Slot(kind, syntaxOffset, ordinal))
+ with :? BadImageFormatException ->
+ invalidData "local slot map" reader.Offset
+
+ List.ofSeq slots
+ finally
+ handle.Free()
+
+// ---------------------------------------------------------------------------
+// EnC Lambda and Closure Map
+// Format (SerializeLambdaMap lines 261-302, UncompressLambdaMap lines 197-259):
+// compressed(methodOrdinal + 1), compressed(-baseline), compressed(closureCount),
+// closureCount * compressed(syntaxOffset - baseline), then until the blob ends:
+// [compressed(syntaxOffset - baseline), compressed(closureOrdinal - MinClosureOrdinal)]
+// per lambda.
+// ---------------------------------------------------------------------------
+
+/// Serializes the EnC Lambda and Closure Map blob for 'info', byte-for-byte as Roslyn's
+/// SerializeLambdaMap. Returns the empty array when there are no lambdas and no closures
+/// (Roslyn's MetadataWriter skips the CDI row in that case; note the method ordinal is
+/// then not persisted and decodes back as UndefinedMethodOrdinal).
+let serializeLambdaMap (info: EncMethodDebugInformation) : byte[] =
+ match info.Closures, info.Lambdas with
+ | [], [] -> Array.empty
+ | closures, lambdas ->
+ if info.MethodOrdinal < -1 then
+ invalidArg (nameof info) $"method ordinal must be >= -1, got %d{info.MethodOrdinal}"
+
+ let builder = BlobBuilder()
+ builder.WriteCompressedInteger(info.MethodOrdinal + 1)
+
+ // Negative offsets are rare (Roslyn: field/property initializers; F#: reserved),
+ // so the baseline is -1 unless a smaller offset exists (Roslyn lines 266-286).
+ let syntaxOffsetBaseline =
+ let closureMin = (-1, closures) ||> List.fold (fun acc c -> min acc c.SyntaxOffset)
+ (closureMin, lambdas) ||> List.fold (fun acc l -> min acc l.SyntaxOffset)
+
+ builder.WriteCompressedInteger(-syntaxOffsetBaseline)
+ builder.WriteCompressedInteger closures.Length
+
+ for closure in closures do
+ builder.WriteCompressedInteger(closure.SyntaxOffset - syntaxOffsetBaseline)
+
+ for lambda in lambdas do
+ if
+ lambda.ClosureOrdinal < MinClosureOrdinal
+ || lambda.ClosureOrdinal >= closures.Length
+ then
+ invalidArg
+ (nameof info)
+ $"lambda closure ordinal %d{lambda.ClosureOrdinal} is outside [%d{MinClosureOrdinal}, %d{closures.Length})"
+
+ builder.WriteCompressedInteger(lambda.SyntaxOffset - syntaxOffsetBaseline)
+ builder.WriteCompressedInteger(lambda.ClosureOrdinal - MinClosureOrdinal)
+
+ builder.ToArray()
+
+/// Deserializes an EnC Lambda and Closure Map blob, byte-for-byte as Roslyn's
+/// UncompressLambdaMap. An empty (or null) blob yields (UndefinedMethodOrdinal, [], []).
+let deserializeLambdaMap (blob: byte[]) : int * EncClosureInfo list * EncLambdaInfo list =
+ if isEmpty blob then
+ UndefinedMethodOrdinal, [], []
+ else
+ let handle = GCHandle.Alloc(blob, GCHandleType.Pinned)
+
+ try
+ let mutable reader =
+ BlobReader(NativePtr.ofNativeInt (handle.AddrOfPinnedObject()), blob.Length)
+
+ let closures = ResizeArray()
+ let lambdas = ResizeArray()
+ let mutable methodOrdinal = UndefinedMethodOrdinal
+
+ try
+ methodOrdinal <- reader.ReadCompressedInteger() - 1
+ let syntaxOffsetBaseline = -reader.ReadCompressedInteger()
+ let closureCount = reader.ReadCompressedInteger()
+
+ for _ in 1..closureCount do
+ let syntaxOffset = reader.ReadCompressedInteger() + syntaxOffsetBaseline
+ closures.Add { SyntaxOffset = syntaxOffset }
+
+ while reader.RemainingBytes > 0 do
+ let syntaxOffset = reader.ReadCompressedInteger() + syntaxOffsetBaseline
+ let closureOrdinal = reader.ReadCompressedInteger() + MinClosureOrdinal
+
+ if closureOrdinal >= closureCount then
+ invalidData "lambda map" reader.Offset
+
+ lambdas.Add
+ {
+ SyntaxOffset = syntaxOffset
+ ClosureOrdinal = closureOrdinal
+ }
+ with :? BadImageFormatException ->
+ invalidData "lambda map" reader.Offset
+
+ methodOrdinal, List.ofSeq closures, List.ofSeq lambdas
+ finally
+ handle.Free()
+
+// ---------------------------------------------------------------------------
+// EnC State Machine State Map
+// Format (SerializeStateMachineStates lines 364-381, UncompressStateMachineStates
+// lines 309-362): compressed(count); when count > 0: compressed(-baseline) followed by
+// count * [compressedSigned(stateNumber), compressed(syntaxOffset - baseline)], entries
+// ordered by syntax offset.
+// ---------------------------------------------------------------------------
+
+/// Serializes the EnC State Machine State Map blob for 'info', byte-for-byte as
+/// Roslyn's SerializeStateMachineStates: entries are sorted by syntax offset (stably,
+/// preserving relative order of equal offsets, which encodes the per-offset relative
+/// ordinal). Returns the empty array when there are no states (no CDI row then).
+let serializeStateMachineStates (info: EncMethodDebugInformation) : byte[] =
+ match info.StateMachineStates with
+ | [] -> Array.empty
+ | states ->
+ let builder = BlobBuilder()
+ builder.WriteCompressedInteger states.Length
+
+ // Unlike the other two blobs the baseline here is min(minOffset, 0)
+ // (Roslyn line 372).
+ let syntaxOffsetBaseline =
+ min (states |> List.map (fun s -> s.SyntaxOffset) |> List.min) 0
+
+ builder.WriteCompressedInteger(-syntaxOffsetBaseline)
+
+ // Roslyn's reader rejects more than 256 entries sharing one syntax offset
+ // (relative ordinal must fit a byte, line 344); fail closed at write time.
+ for _, group in states |> List.groupBy (fun s -> s.SyntaxOffset) do
+ if group.Length > 256 then
+ invalidArg (nameof info) $"more than 256 state machine states share syntax offset %d{group.Head.SyntaxOffset}"
+
+ for state in states |> List.sortBy (fun s -> s.SyntaxOffset) do
+ builder.WriteCompressedSignedInteger state.StateNumber
+ builder.WriteCompressedInteger(state.SyntaxOffset - syntaxOffsetBaseline)
+
+ builder.ToArray()
+
+/// Deserializes an EnC State Machine State Map blob, byte-for-byte as Roslyn's
+/// UncompressStateMachineStates (including the ordered-by-offset and <= 256-per-offset
+/// validations). An empty (or null) blob yields no states.
+let deserializeStateMachineStates (blob: byte[]) : EncStateMachineStateInfo list =
+ if isEmpty blob then
+ []
+ else
+ let handle = GCHandle.Alloc(blob, GCHandleType.Pinned)
+
+ try
+ let mutable reader =
+ BlobReader(NativePtr.ofNativeInt (handle.AddrOfPinnedObject()), blob.Length)
+
+ let states = ResizeArray()
+
+ try
+ let count = reader.ReadCompressedInteger()
+
+ if count > 0 then
+ let syntaxOffsetBaseline = -reader.ReadCompressedInteger()
+ let mutable lastSyntaxOffset = Int32.MinValue
+ let mutable relativeOrdinal = 0
+
+ for _ in 1..count do
+ let stateNumber = reader.ReadCompressedSignedInteger()
+ let syntaxOffset = syntaxOffsetBaseline + reader.ReadCompressedInteger()
+
+ // Entries must be ordered by syntax offset and at most 256 may
+ // share one offset (Roslyn lines 336-347).
+ if syntaxOffset < lastSyntaxOffset then
+ invalidData "state machine state map" reader.Offset
+
+ relativeOrdinal <-
+ if syntaxOffset = lastSyntaxOffset then
+ relativeOrdinal + 1
+ else
+ 0
+
+ if relativeOrdinal > 255 then
+ invalidData "state machine state map" reader.Offset
+
+ states.Add
+ {
+ StateNumber = stateNumber
+ SyntaxOffset = syntaxOffset
+ }
+
+ lastSyntaxOffset <- syntaxOffset
+ with :? BadImageFormatException ->
+ invalidData "state machine state map" reader.Offset
+
+ List.ofSeq states
+ finally
+ handle.Free()
+
+/// Deserializes EnC method debug information from the three blobs (any of which may be
+/// null or empty). Mirrors Roslyn's EditAndContinueMethodDebugInformation.Create.
+let deserialize (slotMapBlob: byte[]) (lambdaMapBlob: byte[]) (stateMachineStateMapBlob: byte[]) : EncMethodDebugInformation =
+ let methodOrdinal, closures, lambdas = deserializeLambdaMap lambdaMapBlob
+
+ {
+ MethodOrdinal = methodOrdinal
+ LocalSlots = deserializeLocalSlots slotMapBlob
+ Closures = closures
+ Lambdas = lambdas
+ StateMachineStates = deserializeStateMachineStates stateMachineStateMapBlob
+ }
+
+// ---------------------------------------------------------------------------
+// Baseline emission bridge: lambda occurrences -> CDI rows for the
+// portable PDB writer. Computed in the fsc emit path when --test:HotReloadDeltas
+// is on; the rows ride the IL writer options into ilwritepdb keyed by IL method name.
+// ---------------------------------------------------------------------------
+
+/// Root-first ordinal chain of an occurrence: the occurrence id stores enclosing
+/// ordinals nearest-enclosing-first, while the key packing wants root-first with the
+/// occurrence's own ordinal last.
+let private occurrenceOrdinalChain (occurrence: LambdaOccurrence) =
+ List.rev occurrence.Id.ParentChain @ [ occurrence.Id.Ordinal ]
+
+/// Builds the EnC method debug information for one member from its lambda
+/// occurrence sequence. Modeling decisions (documented in
+/// docs/hot-reload-closure-mapping.md, "Baseline CDI emission as implemented"):
+/// - MethodOrdinal stays UndefinedMethodOrdinal: F# needs no Roslyn-style
+/// partial-method/ordinal disambiguation at baseline.
+/// - One closure scope per occurrence, and lambda i references closure i: IlxGen
+/// lowers every lambda occurrence (curried group) to its own closure class, so
+/// unlike C# there is no shared display-class scope to model and no static/this-only
+/// lambdas at the typed-tree level (refinement to Static/ThisOnly ordinals is a
+/// lowering-side concern).
+/// - LocalSlots stays empty: the EnC Local Slot Map describes the lowered local slot
+/// layout, an IlxGen emission artifact that is not trivially derivable from the
+/// typed tree; it is omitted rather than guessed.
+/// Fails closed (None) when any occurrence key is not encodable (chains deeper than 2
+/// or ordinals past the packing limits): a partial map could silently mismatch
+/// occurrences, so the method then gets no lambda map at all.
+let tryCreateFromLambdaOccurrences (occurrences: LambdaOccurrence list) : EncMethodDebugInformation option =
+ let keys =
+ occurrences |> List.map (occurrenceOrdinalChain >> tryEncodeOccurrenceKey)
+
+ if keys |> List.exists Option.isNone then
+ None
+ else
+ let keys = keys |> List.map Option.get
+
+ Some
+ {
+ MethodOrdinal = UndefinedMethodOrdinal
+ LocalSlots = []
+ Closures = keys |> List.map (fun key -> { SyntaxOffset = key })
+ Lambdas =
+ keys
+ |> List.mapi (fun closureOrdinal key ->
+ {
+ SyntaxOffset = key
+ ClosureOrdinal = closureOrdinal
+ })
+ StateMachineStates = []
+ }
+
+/// Computes the per-member EnC method debug information of a flag-on compilation from its
+/// implementation files, keyed by IL method (compiled) name. Keying is fail closed: members
+/// without a compiled name, compiled names claimed by more than one member binding anywhere
+/// in the assembly (overloads, same-named members on different types), and members with
+/// unencodable occurrence chains are omitted, so an entry can never describe the wrong
+/// method. Members without lambda occurrences carry no entry.
+let computeMethodEncDebugInfo (g: TcGlobals) (implFiles: CheckedImplFile list) : Map =
+ let allMembers = implFiles |> List.collect (collectMemberLambdaOccurrences g)
+
+ let ambiguousNames =
+ allMembers
+ |> List.choose (fun (symbol, _) -> symbol.CompiledName)
+ |> List.countBy id
+ |> List.filter (fun (_, count) -> count > 1)
+ |> List.map fst
+ |> Set.ofList
+
+ (Map.empty, allMembers)
+ ||> List.fold (fun acc (symbol: SymbolId, occurrences) ->
+ match symbol.CompiledName, occurrences with
+ | Some methName, _ :: _ when not (Set.contains methName ambiguousNames) ->
+ match tryCreateFromLambdaOccurrences occurrences with
+ | Some info -> Map.add methName info acc
+ | None -> acc
+ | _ -> acc)
+
+/// Computes the per-method EnC CustomDebugInformation side channel for the baseline PDB
+/// writer from the optimized implementation files of a flag-on compilation, keyed by IL
+/// method (compiled) name (fail-closed keying per computeMethodEncDebugInfo) — the writer
+/// additionally drops any name that does not identify exactly one IL method row, so a map
+/// can never attach to the wrong method.
+let computeMethodCustomDebugInfoRows
+ (g: TcGlobals)
+ (implFiles: CheckedImplFile list)
+ (stateMachineResumePointsByStructName: Map)
+ : Map =
+
+ // State machine resume points are recorded by the IlxGen lowering against the
+ // emitted state machine STRUCT's full name ('{member}@hotreload...' nested in the
+ // member's enclosing type); the basic name of the struct's simple name is the
+ // owning member's compiled name, which is this conduit's key. Fail closed on
+ // collisions (two recordings reducing to one basic name: same-named members, or
+ // nested CEs lowering several machines inside one member) — a state map must never
+ // describe the wrong method. The PDB writer additionally drops any name that does
+ // not identify exactly one IL method row.
+ let recordedStateMachineStatesByMethodName =
+ let simpleName (fullName: string) =
+ let separatorIndex = fullName.LastIndexOfAny [| '+'; '.' |]
+
+ if separatorIndex >= 0 then
+ fullName.Substring(separatorIndex + 1)
+ else
+ fullName
+
+ let basicName (name: string) =
+ match name.IndexOf('@') with
+ | atIndex when atIndex > 0 -> name.Substring(0, atIndex)
+ | _ -> name
+
+ stateMachineResumePointsByStructName
+ |> Map.toList
+ |> List.map (fun (structFullName, resumePoints) -> basicName (simpleName structFullName), resumePoints)
+ |> List.groupBy fst
+ |> List.choose (fun (methName, group) ->
+ match group with
+ | [ (_, resumePoints) ] when not resumePoints.IsEmpty ->
+ // SyntaxOffset carries the resume point's ORDINAL (state numbers are
+ // positional in the F# lowering), keeping the occurrence-key
+ // philosophy: deterministic ints, not source offsets.
+ let states =
+ resumePoints
+ |> List.sortBy id
+ |> List.mapi (fun ordinal stateNumber ->
+ {
+ StateNumber = stateNumber
+ SyntaxOffset = ordinal
+ })
+
+ Some(methName, states)
+ | _ -> None)
+ |> Map.ofList
+
+ let derivedStateMachineStatesByMethodName =
+ let memberInputs = implFiles |> List.collect (collectMemberDebugInfoInputs g)
+
+ let ambiguousNames =
+ memberInputs
+ |> List.choose (fun input -> input.Symbol.CompiledName)
+ |> List.countBy id
+ |> List.filter (fun (_, count) -> count > 1)
+ |> List.map fst
+ |> Set.ofList
+
+ let tryDeriveStatesFromContinuations (occurrences: LambdaOccurrence list) =
+ let roots =
+ occurrences
+ |> List.filter (fun occurrence -> List.isEmpty occurrence.Id.ParentChain)
+
+ match roots with
+ | [ root ] ->
+ let sameEnd (occurrence: LambdaOccurrence) =
+ occurrence.Range.EndLine = root.Range.EndLine
+ && occurrence.Range.EndColumn = root.Range.EndColumn
+
+ let continuations =
+ occurrences
+ |> List.filter (fun occurrence -> not (List.isEmpty occurrence.Id.ParentChain) && sameEnd occurrence)
+
+ match continuations with
+ | [] -> None
+ | _ ->
+ continuations
+ |> List.mapi (fun ordinal _ ->
+ {
+ StateNumber = ordinal + 1
+ SyntaxOffset = ordinal
+ })
+ |> Some
+ | _ -> None
+
+ (Map.empty, memberInputs)
+ ||> List.fold (fun acc input ->
+ match input.Symbol.CompiledName, input.HasResumableStateMachine with
+ | Some methName, true when not (Set.contains methName ambiguousNames) ->
+ match tryDeriveStatesFromContinuations input.LambdaOccurrences with
+ | Some states -> Map.add methName states acc
+ | None -> acc
+ | _ -> acc)
+
+ let stateMachineStatesByMethodName =
+ (recordedStateMachineStatesByMethodName, derivedStateMachineStatesByMethodName)
+ ||> Map.fold (fun acc methName states ->
+ if Map.containsKey methName acc then
+ acc
+ else
+ Map.add methName states acc)
+
+ let lambdaRows =
+ (Map.empty, computeMethodEncDebugInfo g implFiles)
+ ||> Map.fold (fun acc methName info ->
+ let lambdaMapBlob = serializeLambdaMap info
+
+ if lambdaMapBlob.Length = 0 then
+ acc
+ else
+ // The EnC Local Slot Map stays omitted (see tryCreateFromLambdaOccurrences).
+ Map.add
+ methName
+ [
+ {
+ KindGuid = PortableCustomDebugInfoKinds.encLambdaAndClosureMap
+ Blob = lambdaMapBlob
+ }
+ ]
+ acc)
+
+ (lambdaRows, stateMachineStatesByMethodName)
+ ||> Map.fold (fun acc methName states ->
+ let stateMapBlob =
+ serializeStateMachineStates
+ { EncMethodDebugInformation.Empty with
+ StateMachineStates = states
+ }
+
+ if stateMapBlob.Length = 0 then
+ acc
+ else
+ let stateRow: PdbMethodCustomDebugInfo =
+ {
+ KindGuid = PortableCustomDebugInfoKinds.encStateMachineStateMap
+ Blob = stateMapBlob
+ }
+
+ match Map.tryFind methName acc with
+ | Some rows -> Map.add methName (rows @ [ stateRow ]) acc
+ | None -> Map.add methName [ stateRow ] acc)
+
+// ---------------------------------------------------------------------------
+// Baseline read bridge: portable-PDB EnC CDI rows -> the per-method map the
+// hot reload session baseline (FSharpEmitBaseline.EncMethodDebugInfos) exposes to the
+// generation-aware closure lowering.
+// ---------------------------------------------------------------------------
+
+/// Decodes every method-level EnC CustomDebugInformation row of a portable PDB image into
+/// per-method EnC debug information, keyed by MethodDef token (0x06xxxxxx). The CDI parent
+/// of the EnC rows is always a MethodDef handle, so token keying is unambiguous here — the
+/// name keying on the write side exists only because the PDB writer lacks tokens.
+/// Fail safe: a null/empty or non-PDB image yields the empty map (back-compat with
+/// baselines compiled without --test:HotReloadDeltas or whose PDBs carry no EnC rows), and a method
+/// whose blobs do not decode is omitted rather than guessed.
+let readEncMethodDebugInfoFromPortablePdb (pdbBytes: byte[]) : Map =
+ if isEmpty pdbBytes then
+ Map.empty
+ else
+ try
+ use provider =
+ MetadataReaderProvider.FromPortablePdbImage(ImmutableArray.CreateRange pdbBytes)
+
+ let reader = provider.GetMetadataReader()
+
+ let slotMapBlobs = Dictionary()
+ let lambdaMapBlobs = Dictionary()
+ let stateMapBlobs = Dictionary()
+
+ for cdiHandle in reader.CustomDebugInformation do
+ let cdi = reader.GetCustomDebugInformation cdiHandle
+
+ if cdi.Parent.Kind = HandleKind.MethodDefinition then
+ let methodToken = MetadataTokens.GetToken cdi.Parent
+ let kind = reader.GetGuid cdi.Kind
+
+ if kind = PortableCustomDebugInfoKinds.encLocalSlotMap then
+ slotMapBlobs[methodToken] <- reader.GetBlobBytes cdi.Value
+ elif kind = PortableCustomDebugInfoKinds.encLambdaAndClosureMap then
+ lambdaMapBlobs[methodToken] <- reader.GetBlobBytes cdi.Value
+ elif kind = PortableCustomDebugInfoKinds.encStateMachineStateMap then
+ stateMapBlobs[methodToken] <- reader.GetBlobBytes cdi.Value
+
+ let methodTokens =
+ Seq.concat [ slotMapBlobs.Keys :> seq; lambdaMapBlobs.Keys; stateMapBlobs.Keys ]
+ |> Seq.distinct
+
+ let tryBlob (blobs: Dictionary) token =
+ match blobs.TryGetValue token with
+ | true, blob -> blob
+ | _ -> Array.empty
+
+ (Map.empty, methodTokens)
+ ||> Seq.fold (fun acc token ->
+ try
+ let info =
+ deserialize (tryBlob slotMapBlobs token) (tryBlob lambdaMapBlobs token) (tryBlob stateMapBlobs token)
+
+ Map.add token info acc
+ with :? InvalidDataException ->
+ // Fail closed per method: an undecodable blob never yields a partial
+ // (and so potentially mismatched) map for its method.
+ acc)
+ with :? BadImageFormatException ->
+ // Not a portable PDB image (or a corrupted one): the session still starts,
+ // with no per-method EnC information.
+ Map.empty
+
+/// Reads the F#-owned allocation-ordered synthesized-name snapshot from a portable PDB.
+/// None means either the record is absent (old baseline / flag-off baseline) or invalid;
+/// callers must then fall back to IL reconstruction rather than trusting a partial layout.
+let readSynthesizedNameSnapshotFromPortablePdb (pdbBytes: byte[]) : Map option =
+ if isEmpty pdbBytes then
+ None
+ else
+ try
+ use provider =
+ MetadataReaderProvider.FromPortablePdbImage(ImmutableArray.CreateRange pdbBytes)
+
+ let reader = provider.GetMetadataReader()
+
+ let blobs =
+ [
+ for cdiHandle in reader.CustomDebugInformation do
+ let cdi = reader.GetCustomDebugInformation cdiHandle
+
+ if cdi.Parent.Kind = HandleKind.ModuleDefinition then
+ let kind = reader.GetGuid cdi.Kind
+
+ if kind = PortableCustomDebugInfoKinds.fsharpSynthesizedNameSnapshot then
+ reader.GetBlobBytes cdi.Value
+ ]
+
+ match blobs with
+ | [ blob ] -> Some(deserializeSynthesizedNameSnapshot blob)
+ | _ -> None
+ with
+ | :? BadImageFormatException
+ | :? InvalidDataException -> None
diff --git a/src/Compiler/CodeGen/FSharpDefinitionIndex.fs b/src/Compiler/CodeGen/FSharpDefinitionIndex.fs
new file mode 100644
index 00000000000..5cc78697473
--- /dev/null
+++ b/src/Compiler/CodeGen/FSharpDefinitionIndex.fs
@@ -0,0 +1,105 @@
+module internal FSharp.Compiler.CodeGen.FSharpDefinitionIndex
+
+open System.Collections.Generic
+
+/// Represents the status of a definition row tracked in the index.
+type private EntryStatus<'T> =
+ | Added of rowId: int * item: 'T
+ | Existing of rowId: int * item: 'T
+
+/// F# analogue of Roslyn's DefinitionIndex
+/// Track row ids for definitions reused from the baseline or added in this generation.
+type DefinitionIndex<'T when 'T: not null and 'T: equality>(getExistingRowId: 'T -> int option, lastRowId: int) =
+ let added = Dictionary<'T, int>()
+ let rows = ResizeArray>()
+ let map = Dictionary()
+ let firstRowId = lastRowId + 1
+ let mutable frozen = false
+
+ let tryGetExistingRowId item =
+ match getExistingRowId item with
+ | Some rowId when rowId > 0 ->
+ map[rowId] <- item
+ Some rowId
+ | _ -> None
+
+ let getRowIdCore item =
+ match added.TryGetValue item with
+ | true, rowId -> rowId
+ | false, _ ->
+ match tryGetExistingRowId item with
+ | Some rowId -> rowId
+ | None -> invalidOp "Row id not found for definition."
+
+ let ensureNotFrozen () =
+ if frozen then
+ invalidOp "Definition index has been frozen."
+
+ let freeze () =
+ if not frozen then
+ frozen <- true
+
+ rows.Sort(fun left right ->
+ let rowId entry =
+ match entry with
+ | Added(rowId, _) -> rowId
+ | Existing(rowId, _) -> rowId
+
+ compare (rowId left) (rowId right))
+
+ member _.Add(item: 'T) =
+ ensureNotFrozen ()
+
+ if added.ContainsKey item then
+ invalidOp "Definition has already been added."
+
+ let rowId = firstRowId + added.Count
+ added.Add(item, rowId)
+ map[rowId] <- item
+ rows.Add(Added(rowId, item))
+ rowId
+
+ member _.AddExisting(item: 'T) =
+ ensureNotFrozen ()
+
+ match tryGetExistingRowId item with
+ | Some rowId -> rows.Add(Existing(rowId, item))
+ | None -> invalidOp "Existing row id not found for definition."
+
+ member _.GetRowId(item: 'T) = getRowIdCore item
+
+ member _.Contains(item: 'T) =
+ match added.TryGetValue item with
+ | true, _ -> true
+ | _ -> Option.isSome (tryGetExistingRowId item)
+
+ member _.IsAdded(item: 'T) = added.ContainsKey item
+
+ member _.TryGetDefinition(rowId: int) =
+ match map.TryGetValue rowId with
+ | true, item -> Some item
+ | _ -> None
+
+ member _.FirstRowId = firstRowId
+
+ member _.NextRowId = firstRowId + added.Count
+
+ member _.IsFrozen = frozen
+
+ member _.Rows =
+ freeze ()
+
+ rows
+ |> Seq.map (fun entry ->
+ match entry with
+ | Added(rowId, item) -> struct (rowId, item, true)
+ | Existing(rowId, item) -> struct (rowId, item, false))
+ |> Seq.toList
+
+ member _.Added =
+ freeze ()
+
+ added
+ |> Seq.map (fun kvp -> struct (kvp.Value, kvp.Key))
+ |> Seq.sortBy (fun struct (rowId, _) -> rowId)
+ |> Seq.toList
diff --git a/src/Compiler/CodeGen/HotReloadBaseline.fs b/src/Compiler/CodeGen/HotReloadBaseline.fs
new file mode 100644
index 00000000000..09ae4626f18
--- /dev/null
+++ b/src/Compiler/CodeGen/HotReloadBaseline.fs
@@ -0,0 +1,1732 @@
+module internal FSharp.Compiler.HotReloadBaseline
+
+open System
+open System.Collections.Generic
+open System.Collections.Immutable
+open System.Reflection
+open FSharp.Compiler.AbstractIL.IL
+open FSharp.Compiler.AbstractIL.ILBinaryWriter
+open FSharp.Compiler.AbstractIL.BinaryConstants
+open FSharp.Compiler.AbstractIL.ILDeltaHandles
+open FSharp.Compiler.AbstractIL.DeltaMetadataTypes
+open FSharp.Compiler.EncMethodDebugInformation
+open FSharp.Compiler.GeneratedNames
+open FSharp.Compiler.IlxGen
+open FSharp.Compiler.TcGlobals
+open FSharp.Compiler.TypedTree
+
+module ILBaselineReader = FSharp.Compiler.AbstractIL.ILBaselineReader
+module ActiveStatementAnalysis = FSharp.Compiler.HotReload.ActiveStatementAnalysis
+
+open FSharp.Compiler.Syntax.PrettyNaming
+open FSharp.Compiler.EnvironmentHelpers
+
+let private tableCount = DeltaTokens.TableCount
+
+[]
+let private TraceHeapOffsetsFlagName = "FSHARP_HOTRELOAD_TRACE_HEAP_OFFSETS"
+
+let private traceHeapOffsets = lazy (isEnvVarTruthy TraceHeapOffsetsFlagName)
+
+let private traceClosureNames =
+ lazy (isEnvVarTruthy "FSHARP_HOTRELOAD_TRACE_CLOSURENAMES")
+
+/// Align a size to a 4-byte boundary (stream alignment per ECMA-335).
+/// Used for Blob and UserString heap cumulative tracking, per Roslyn behavior.
+let private align4 value = (value + 3) &&& ~~~3
+
+/// Metadata describing a method body that was added or changed in a delta.
+type AddedOrChangedMethodInfo =
+ {
+ MethodToken: int
+ LocalSignatureToken: int
+ CodeOffset: int
+ CodeLength: int
+ }
+
+/// Stable identifier for a method definition used when correlating baseline tokens.
+type MethodDefinitionKey = FSharp.Compiler.AbstractIL.DeltaMetadataTypes.MethodDefinitionKey
+
+/// Baseline metadata handles reused to keep heap offsets stable across deltas.
+/// Stable identifier for a method parameter (sequence number within a method).
+type ParameterDefinitionKey = FSharp.Compiler.AbstractIL.DeltaMetadataTypes.ParameterDefinitionKey
+
+/// Stable identifier for a field definition in the baseline assembly.
+type FieldDefinitionKey = FSharp.Compiler.AbstractIL.DeltaMetadataTypes.FieldDefinitionKey
+
+/// Stable identifier for a property definition (including indexer parameter shapes).
+type PropertyDefinitionKey = FSharp.Compiler.AbstractIL.DeltaMetadataTypes.PropertyDefinitionKey
+
+/// Stable identifier for an event definition in the baseline assembly.
+type EventDefinitionKey = FSharp.Compiler.AbstractIL.DeltaMetadataTypes.EventDefinitionKey
+
+type MethodDefinitionMetadataHandles =
+ {
+ NameOffset: StringOffset option
+ SignatureOffset: BlobOffset option
+ FirstParameterRowId: int option
+ Rva: int option
+ Attributes: MethodAttributes option
+ ImplAttributes: MethodImplAttributes option
+ }
+
+///
+/// Typed identity for a TypeRef resolution scope. Baseline TypeRef tables routinely contain
+/// duplicate type names under different scopes (e.g. two 'Object' rows under different
+/// AssemblyRefs, 'LowPriority' under two namespaces) and nested TypeRefs whose scope is the
+/// enclosing TypeRef row, so a TypeRef can only be matched by its full scope chain - never by
+/// name alone.
+///
+[]
+type AssemblyReferenceKey =
+ {
+ Name: string
+ MajorVersion: int
+ MinorVersion: int
+ BuildNumber: int
+ RevisionNumber: int
+ Culture: string
+ PublicKeyOrToken: byte list
+ Flags: int
+ }
+
+[]
+type TypeReferenceScope =
+ /// TypeRef resolved against an AssemblyRef row, identified by its complete metadata identity.
+ | Assembly of assembly: AssemblyReferenceKey
+ /// Nested TypeRef whose resolution scope is its enclosing TypeRef.
+ | Nested of enclosing: TypeReferenceKey
+
+and TypeReferenceKey =
+ {
+ Scope: TypeReferenceScope
+ Namespace: string
+ Name: string
+ }
+
+type ParameterDefinitionMetadataHandles =
+ {
+ NameOffset: StringOffset option
+ /// Baseline parameter name (resolved from the #Strings heap). Param row re-emission
+ /// reuses the baseline name offset only when the fresh compile's name matches;
+ /// a differing name (parameter rename under UpdateParameters) writes the fresh name
+ /// into the delta string heap instead.
+ Name: string option
+ RowId: int option
+ }
+
+type PropertyDefinitionMetadataHandles =
+ {
+ NameOffset: StringOffset option
+ SignatureOffset: BlobOffset option
+ }
+
+type EventDefinitionMetadataHandles = { NameOffset: StringOffset option }
+
+/// Content snapshot of a baseline MemberRef row, used by the delta emitter to VALIDATE
+/// positional token passthrough (the fresh in-memory compile's MemberRef row order can
+/// shift relative to the baseline — e.g. when an added lambda changes the order of first
+/// use — so a row id is only trusted when its content matches the baseline row).
+type BaselineMemberRefRow =
+ {
+ Name: string
+ /// Decoded MemberRefParent as a metadata token (0x02/0x01/0x1A/0x06/0x1B tables).
+ ParentToken: int
+ /// Signature blob bytes (baseline coordinates).
+ Signature: byte[]
+ }
+
+/// Content snapshot of a baseline CustomAttribute row. Attribute edits on EXISTING members
+/// pair the fresh compile's attributes against these rows so changed attributes
+/// UPDATE the row in place and removed attributes ZERO it (Roslyn DeltaMetadataWriter
+/// parity, validated against the csharp_enc_reference attr_change/attr_remove templates).
+type BaselineCustomAttributeRow =
+ {
+ /// Decoded HasCustomAttribute parent as a metadata token.
+ ParentToken: int
+ /// Decoded CustomAttributeType constructor as a metadata token (0x06/0x0A tables).
+ ConstructorToken: int
+ /// Value blob bytes (baseline coordinates).
+ Value: byte[]
+ }
+
+type BaselineHandleCache =
+ {
+ MethodHandles: Map
+ ParameterHandles: Map
+ PropertyHandles: Map
+ EventHandles: Map
+ }
+
+ static member Empty =
+ {
+ MethodHandles = Map.empty
+ ParameterHandles = Map.empty
+ PropertyHandles = Map.empty
+ EventHandles = Map.empty
+ }
+
+type MethodSemanticsAssociation = FSharp.Compiler.AbstractIL.DeltaMetadataTypes.MethodSemanticsAssociation
+
+type MethodSemanticsEntry =
+ {
+ RowId: int
+ Attributes: MethodSemanticsAttributes
+ Association: MethodSemanticsAssociation
+ }
+
+type SynthesizedTypeShape =
+ {
+ GenericArity: int
+ BaseType: string option
+ InterfaceTypes: string list
+ FieldTypeNames: string list
+ MethodNameAndArities: (string * int) list
+ }
+
+let rec private ilTypeShapeName (ty: ILType) =
+ match ty with
+ | ILType.Void -> "void"
+ | ILType.TypeVar ordinal -> "!" + string ordinal
+ | ILType.Array(ILArrayShape dimensions, elementType) ->
+ ilTypeShapeName elementType
+ + "["
+ + String(',', max 0 (dimensions.Length - 1))
+ + "]"
+ | ILType.Value typeSpec -> "valuetype " + ilTypeSpecShapeName typeSpec
+ | ILType.Boxed typeSpec -> "class " + ilTypeSpecShapeName typeSpec
+ | ILType.Ptr elementType -> ilTypeShapeName elementType + "*"
+ | ILType.Byref elementType -> ilTypeShapeName elementType + "&"
+ | ILType.FunctionPointer signature ->
+ let args = signature.ArgTypes |> List.map ilTypeShapeName |> String.concat ","
+ $"fnptr({args})->{ilTypeShapeName signature.ReturnType}"
+ | ILType.Modified(required, modifier, modifiedType) ->
+ let modifierKind = if required then "modreq" else "modopt"
+ $"{modifierKind}({modifier.QualifiedName}) {ilTypeShapeName modifiedType}"
+
+and private ilTypeSpecShapeName (typeSpec: ILTypeSpec) =
+ if List.isEmpty typeSpec.GenericArgs then
+ typeSpec.TypeRef.QualifiedName
+ else
+ let args = typeSpec.GenericArgs |> List.map ilTypeShapeName |> String.concat ","
+ $"{typeSpec.TypeRef.QualifiedName}<{args}>"
+
+let internal shapeOfSynthesizedTypeDef (typeDef: ILTypeDef) : SynthesizedTypeShape =
+ {
+ GenericArity = typeDef.GenericParams.Length
+ BaseType = typeDef.Extends.Value |> Option.map ilTypeShapeName
+ InterfaceTypes =
+ typeDef.Implements.Value
+ |> List.map (fun implementation -> ilTypeShapeName implementation.Type)
+ |> List.sort
+ FieldTypeNames =
+ typeDef.Fields.AsList()
+ |> List.map (fun fieldDef -> ilTypeShapeName fieldDef.FieldType)
+ |> List.sort
+ MethodNameAndArities =
+ typeDef.Methods.AsList()
+ |> List.map (fun methodDef -> methodDef.Name, methodDef.GenericParams.Length)
+ |> List.distinct
+ |> List.sort
+ }
+
+/// Portable PDB snapshot captured during baseline emission.
+type PortablePdbSnapshot =
+ {
+ Bytes: byte[]
+ TableRowCounts: ImmutableArray
+ EntryPointToken: int option
+ }
+
+[]
+type SynthesizedNameSnapshotSource =
+ | Recorded
+ | Reconstructed
+
+///
+/// Represents the captured state of a baseline emission, mirroring Roslyn's EmitBaseline. It stores metadata
+/// snapshots along with stable token maps so delta emission can reuse pre-existing metadata handles.
+///
+type FSharpEmitBaseline =
+ {
+ ModuleId: Guid
+ EncId: Guid
+ EncBaseId: Guid
+ NextGeneration: int
+ ModuleNameOffset: StringOffset option
+ Metadata: MetadataSnapshot
+ TokenMappings: ILTokenMappings
+ TypeTokens: Map
+ MethodTokens: Map
+ FieldTokens: Map
+ PropertyTokens: Map
+ EventTokens: Map
+ PropertyMapEntries: Map
+ EventMapEntries: Map
+ MethodSemanticsEntries: Map
+ IlxGenEnvironment: IlxGenEnvSnapshot option
+ PortablePdb: PortablePdbSnapshot option
+ SynthesizedNameSnapshot: Map
+ SynthesizedNameSnapshotSource: SynthesizedNameSnapshotSource
+ SynthesizedTypeShapes: Map
+ MetadataHandles: BaselineHandleCache
+ TypeReferenceTokens: Map
+ AssemblyReferenceTokens: Map
+ /// Baseline MemberRef row contents keyed by row id, for content-validated token
+ /// passthrough in delta emission (extended with delta-added rows on chaining).
+ /// Empty for baselines whose bytes were unavailable — passthrough then stays
+ /// positional (legacy behavior).
+ MemberReferenceRows: Map
+ /// Baseline TypeSpec signature blobs keyed by row id, for content-validated
+ /// TypeSpec token reuse. An unmatched fresh TypeSpec appends a new delta row;
+ /// appended rows chain into this map for the next generation's content search.
+ TypeSpecSignatures: Map
+ /// Baseline CustomAttribute row contents keyed by row id (decoded parent/ctor
+ /// tokens + value blob). Attribute edits on existing members update/zero these
+ /// rows in place; rows emitted by a delta chain into the map for the next
+ /// generation. Empty for baselines whose bytes were unavailable — CA emission
+ /// then stays append-only (legacy behavior).
+ CustomAttributeRows: Map
+ TableEntriesAdded: int[]
+ StringStreamLengthAdded: int
+ UserStringStreamLengthAdded: int
+ BlobStreamLengthAdded: int
+ GuidStreamLengthAdded: int
+ AddedOrChangedMethods: AddedOrChangedMethodInfo list
+ ///
+ /// Per-method Edit-and-Continue debug information (lambda/closure occurrence maps),
+ /// keyed by MethodDef token (0x06xxxxxx). Decoded from the baseline portable PDB's EnC
+ /// CustomDebugInformation rows when the baseline is captured, and refreshed in memory
+ /// for updated/added methods as each delta is applied (see chainEncMethodDebugInfos).
+ /// A baseline compiled without --test:HotReloadDeltas (or whose PDB carries no EnC rows)
+ /// yields the empty map.
+ ///
+ EncMethodDebugInfos: Map
+ ///
+ /// Per-method closure-class name tables (occurrence-chain -> emitted closure type
+ /// name), keyed by MethodDef token (0x06xxxxxx) — the companion of
+ /// EncMethodDebugInfos. The Roslyn CDI blob formats carry no name slots, and like
+ /// Roslyn (which recomputes C# names from DebugId alone) F# does not persist
+ /// names: under the occurrence-derived derivation baseline closure names are a pure function of
+ /// occurrence identity ({member}@hotreload#g0_o{chain}), so the tables are
+ /// reconstructed from the decoded EnC CDI occurrence keys — for in-process
+ /// captures and for baselines read back from disk in another process alike (see
+ /// deriveEncClosureNamesFromEncDebugInfos for the fail-closed rules) — and
+ /// chained in memory like EncMethodDebugInfos as deltas allocate
+ /// generation-suffixed names for added occurrences. Empty for flag-off,
+ /// replay-named (non-derivable) and mid-session-recapture baselines: occurrence-keyed naming then stays inert
+ /// and delta compiles keep sequence replay (fail closed).
+ ///
+ EncClosureNames: Map>
+ ///
+ /// Committed per-method sequence points keyed by MethodDef token (0x06xxxxxx) — the
+ /// debugger's current view of each method's lines. Decoded from the baseline
+ /// portable PDB when the session starts and REPLACED wholesale after every committed delta
+ /// with the fresh compile's sequence points (updated methods get their delta-PDB points;
+ /// unchanged methods get their line-shift-adjusted points, matching the line updates the
+ /// host applied to the debugger). Line-shift detection and active-statement remapping diff
+ /// fresh compiles against this map; empty when the baseline had no portable PDB, which
+ /// keeps the sequence-point/active-statement machinery inert (fail closed).
+ ///
+ SequencePointSnapshots: Map
+ }
+
+type private BaselineMaps =
+ {
+ TypeTokens: Map
+ MethodTokens: Map
+ FieldTokens: Map
+ PropertyTokens: Map
+ EventTokens: Map
+ PropertyMapEntries: Map
+ EventMapEntries: Map
+ SynthesizedTypeShapes: Map
+ }
+
+let private emptyMaps =
+ {
+ TypeTokens = Map.empty
+ MethodTokens = Map.empty
+ FieldTokens = Map.empty
+ PropertyTokens = Map.empty
+ EventTokens = Map.empty
+ PropertyMapEntries = Map.empty
+ EventMapEntries = Map.empty
+ SynthesizedTypeShapes = Map.empty
+ }
+
+let internal collectSynthesizedNameSnapshot (ilModule: ILModuleDef) =
+ let buckets = Dictionary>(StringComparer.Ordinal)
+
+ let recordName (name: string) =
+ if not (String.IsNullOrWhiteSpace name) && IsCompilerGeneratedName name then
+ let basicName = GetBasicNameOfPossibleCompilerGeneratedName name
+ let mapKey = GeneratedNames.SynthesizedNameMapKey basicName
+
+ if not (String.IsNullOrWhiteSpace mapKey) then
+ let bucket =
+ match buckets.TryGetValue mapKey with
+ | true, existing -> existing
+ | _ ->
+ let created = ResizeArray()
+ buckets[mapKey] <- created
+ created
+
+ if not (bucket.Contains name) then
+ bucket.Add(name)
+
+ let rec collectTypeDef (typeDef: ILTypeDef) =
+ recordName typeDef.Name
+
+ typeDef.Fields.AsList() |> List.iter (fun fieldDef -> recordName fieldDef.Name)
+
+ typeDef.Methods.AsList()
+ |> List.iter (fun methodDef -> recordName methodDef.Name)
+
+ typeDef.Properties.AsList()
+ |> List.iter (fun propertyDef -> recordName propertyDef.Name)
+
+ typeDef.Events.AsList() |> List.iter (fun eventDef -> recordName eventDef.Name)
+
+ typeDef.NestedTypes.AsList() |> List.iter collectTypeDef
+
+ ilModule.TypeDefs.AsList() |> List.iter collectTypeDef
+
+ buckets
+ |> Seq.map (fun (KeyValue(key, bucket)) -> key, bucket.ToArray())
+ |> Map.ofSeq
+
+/// Captures the allocation-slot snapshot from the synthesized-name map that IlxGen
+/// just used, replacing replay names with the final names IlxGen emitted where the
+/// occurrence-keyed closure allocator overrode them.
+let internal collectRecordedSynthesizedNameSnapshot (compilerGlobalState: obj) (map: ICompilerGeneratedNameMap) =
+ let overrides =
+ FSharp.Compiler.ClosureNameAllocationState.getSynthesizedNameOverrides compilerGlobalState
+
+ map.Snapshot
+ |> FSharp.Compiler.ClosureNameAllocationState.applySynthesizedNameOverrides overrides
+
+///
+/// Populate the baseline token maps by walking type definitions and their nested members.
+///
+let rec private collectType
+ (tokenMappings: ILTokenMappings)
+ (scope: ILScopeRef)
+ (enclosing: ILTypeDef list)
+ (maps: BaselineMaps)
+ (tdef: ILTypeDef)
+ : BaselineMaps =
+ let typeRef = mkRefForNestedILTypeDef scope (enclosing, tdef)
+ let typeName = typeRef.FullName
+ let typeToken = tokenMappings.TypeDefTokenMap(enclosing, tdef)
+
+ let maps =
+ { maps with
+ TypeTokens = maps.TypeTokens |> Map.add typeName typeToken
+ }
+
+ let maps =
+ if IsCompilerGeneratedName tdef.Name then
+ { maps with
+ SynthesizedTypeShapes = maps.SynthesizedTypeShapes |> Map.add typeName (shapeOfSynthesizedTypeDef tdef)
+ }
+ else
+ maps
+
+ let maps =
+ tdef.Methods.AsList()
+ |> List.fold
+ (fun (acc: BaselineMaps) mdef ->
+ let key =
+ {
+ DeclaringType = typeName
+ Name = mdef.Name
+ GenericArity = mdef.GenericParams.Length
+ ParameterTypes = mdef.ParameterTypes
+ ReturnType = mdef.Return.Type
+ }
+
+ let token = tokenMappings.MethodDefTokenMap (enclosing, tdef) mdef
+
+ { acc with
+ MethodTokens = acc.MethodTokens |> Map.add key token
+ })
+ maps
+
+ let maps =
+ tdef.Fields.AsList()
+ |> List.fold
+ (fun (acc: BaselineMaps) fdef ->
+ let key =
+ {
+ DeclaringType = typeName
+ Name = fdef.Name
+ FieldType = fdef.FieldType
+ }
+
+ let token = tokenMappings.FieldDefTokenMap (enclosing, tdef) fdef
+
+ { acc with
+ FieldTokens = acc.FieldTokens |> Map.add key token
+ })
+ maps
+
+ let propertyDefs = tdef.Properties.AsList()
+
+ let maps =
+ propertyDefs
+ |> List.fold
+ (fun (acc: BaselineMaps) pdef ->
+ let key =
+ {
+ DeclaringType = typeName
+ Name = pdef.Name
+ PropertyType = pdef.PropertyType
+ IndexParameterTypes = List.ofSeq pdef.Args
+ }
+
+ let token = tokenMappings.PropertyTokenMap (enclosing, tdef) pdef
+
+ { acc with
+ PropertyTokens = acc.PropertyTokens |> Map.add key token
+ })
+ maps
+
+ let maps =
+ match propertyDefs with
+ | first :: _ ->
+ let token = tokenMappings.PropertyTokenMap (enclosing, tdef) first
+ let rowId = token &&& 0x00FFFFFF
+
+ { maps with
+ PropertyMapEntries = maps.PropertyMapEntries |> Map.add typeName rowId
+ }
+ | [] -> maps
+
+ let eventDefs = tdef.Events.AsList()
+
+ let maps =
+ eventDefs
+ |> List.fold
+ (fun (acc: BaselineMaps) edef ->
+ let key =
+ {
+ DeclaringType = typeName
+ Name = edef.Name
+ EventType = edef.EventType
+ }
+
+ let token = tokenMappings.EventTokenMap (enclosing, tdef) edef
+
+ { acc with
+ EventTokens = acc.EventTokens |> Map.add key token
+ })
+ maps
+
+ let maps =
+ match eventDefs with
+ | first :: _ ->
+ let token = tokenMappings.EventTokenMap (enclosing, tdef) first
+ let rowId = token &&& 0x00FFFFFF
+
+ { maps with
+ EventMapEntries = maps.EventMapEntries |> Map.add typeName rowId
+ }
+ | [] -> maps
+
+ tdef.NestedTypes.AsList()
+ |> List.fold (collectType tokenMappings scope (enclosing @ [ tdef ])) maps
+
+let private methodKeyFromRef (methodRef: ILMethodRef) =
+ {
+ MethodDefinitionKey.DeclaringType = methodRef.DeclaringTypeRef.FullName
+ Name = methodRef.Name
+ GenericArity = methodRef.GenericArity
+ ParameterTypes = methodRef.ArgTypes |> Seq.toList
+ ReturnType = methodRef.ReturnType
+ }
+
+let collectMethodSemanticsEntries
+ (ilModule: ILModuleDef)
+ (methodTokens: Map)
+ (propertyTokens: Map)
+ (eventTokens: Map)
+ =
+ let entries =
+ Dictionary>(HashIdentity.Structural)
+
+ let mutable nextRowId = 0
+
+ let addEntry methodKey entry =
+ match entries.TryGetValue methodKey with
+ | true, bucket -> bucket.Add entry
+ | _ ->
+ let bucket = ResizeArray()
+ bucket.Add entry
+ entries[methodKey] <- bucket
+
+ let tryAddSemantics association attributes methodRefOpt =
+ match methodRefOpt with
+ | None -> ()
+ | Some methodRef ->
+ let methodKey = methodKeyFromRef methodRef
+
+ if methodTokens.ContainsKey methodKey then
+ nextRowId <- nextRowId + 1
+
+ addEntry
+ methodKey
+ {
+ RowId = nextRowId
+ Attributes = attributes
+ Association = association
+ }
+
+ let rec visitType enclosing (typeDef: ILTypeDef) =
+ let typeRef = mkRefForNestedILTypeDef ILScopeRef.Local (enclosing, typeDef)
+ let typeName = typeRef.FullName
+
+ let buildPropertyKey (prop: ILPropertyDef) =
+ {
+ PropertyDefinitionKey.DeclaringType = typeName
+ Name = prop.Name
+ PropertyType = prop.PropertyType
+ IndexParameterTypes = List.ofSeq prop.Args
+ }
+
+ let buildEventKey (eventDef: ILEventDef) =
+ {
+ EventDefinitionKey.DeclaringType = typeName
+ Name = eventDef.Name
+ EventType = eventDef.EventType
+ }
+
+ for prop in typeDef.Properties.AsList() do
+ let propertyKey = buildPropertyKey prop
+
+ match propertyTokens |> Map.tryFind propertyKey with
+ | Some propertyToken ->
+ let rowId = propertyToken &&& 0x00FFFFFF
+ let association = MethodSemanticsAssociation.PropertyAssociation(propertyKey, rowId)
+ tryAddSemantics association MethodSemanticsAttributes.Setter prop.SetMethod
+ tryAddSemantics association MethodSemanticsAttributes.Getter prop.GetMethod
+ | None -> ()
+
+ for eventDef in typeDef.Events.AsList() do
+ let eventKey = buildEventKey eventDef
+
+ match eventTokens |> Map.tryFind eventKey with
+ | Some eventToken ->
+ let rowId = eventToken &&& 0x00FFFFFF
+ let association = MethodSemanticsAssociation.EventAssociation(eventKey, rowId)
+ tryAddSemantics association MethodSemanticsAttributes.Adder (Some eventDef.AddMethod)
+ tryAddSemantics association MethodSemanticsAttributes.Remover (Some eventDef.RemoveMethod)
+
+ eventDef.FireMethod
+ |> Option.iter (fun fire -> tryAddSemantics association MethodSemanticsAttributes.Raiser (Some fire))
+
+ eventDef.OtherMethods
+ |> List.iter (fun other -> tryAddSemantics association MethodSemanticsAttributes.Other (Some other))
+ | None -> ()
+
+ typeDef.NestedTypes.AsList()
+ |> List.iter (fun nested -> visitType (enclosing @ [ typeDef ]) nested)
+
+ ilModule.TypeDefs.AsList() |> List.iter (visitType [])
+
+ entries |> Seq.map (fun kvp -> kvp.Key, kvp.Value |> Seq.toList) |> Map.ofSeq
+
+/// Same character cleanup IlxGen applies to closure base names before minting type
+/// names (IlxGen.CleanUpGeneratedTypeName, not exposed through IlxGen.fsi).
+let private cleanUpGeneratedTypeName (nm: string) =
+ if nm.IndexOfAny IllegalCharactersInTypeAndNamespaceNames = -1 then
+ nm
+ else
+ (nm, IllegalCharactersInTypeAndNamespaceNames)
+ ||> Array.fold (fun nm c -> nm.Replace(string c, "-"))
+
+/// Simple (unqualified) names of every TypeDef in the module, nested types included.
+let internal collectTypeDefSimpleNames (ilModule: ILModuleDef) : Set =
+ let names = HashSet(StringComparer.Ordinal)
+
+ let rec visit (typeDef: ILTypeDef) =
+ names.Add typeDef.Name |> ignore
+ typeDef.NestedTypes.AsList() |> List.iter visit
+
+ ilModule.TypeDefs.AsList() |> List.iter visit
+ Set.ofSeq names
+
+///
+/// Reconstructs the per-method occurrence-chain -> closure-class-name tables from the
+/// decoded EnC CDI occurrence keys alone. Under occurrence-derived baseline
+/// naming, a flag-on baseline compile names every mapped closure
+/// {memberCompiledName}@hotreload#g0_o{chain} — a pure function of the identity
+/// the CDI rows persist — so a session started from the on-disk baseline in another
+/// process re-derives exactly the tables the emitting compile installed, with no
+/// in-memory carry-over. Fail closed twice:
+/// - a baseline containing any generation-suffixed TypeDef of generation >= 1 is a
+/// mid-session artifact (a flag-on recapture emitted under an active session, whose
+/// added closures carry their first-allocation generation); its names are NOT
+/// derivable from generation-0 identity, so no table is reconstructed at all;
+/// - per occurrence, a derived name must exist as a baseline TypeDef simple name.
+/// Occurrences that never lowered to a closure class are omitted, while surviving
+/// occurrence-derived closures stay replayable. A reconstructed table can never
+/// claim a name the baseline does not contain.
+///
+let deriveEncClosureNamesFromEncDebugInfos
+ (encMethodDebugInfos: Map)
+ (methodNamesByToken: Map)
+ (typeDefSimpleNames: Set)
+ : Map> =
+
+ if Map.isEmpty encMethodDebugInfos then
+ Map.empty
+ else
+ let hasMidSessionClosureNames =
+ typeDefSimpleNames
+ |> Set.exists (fun name ->
+ match GeneratedNames.TryGetHotReloadNameGeneration name with
+ | Some generation -> generation >= 1
+ | None -> false)
+
+ if hasMidSessionClosureNames then
+ Map.empty
+ else
+ let hasReplayNamedTypeDef nameBase =
+ let prefix = nameBase + "@hotreload"
+
+ typeDefSimpleNames
+ |> Set.exists (fun name ->
+ name.StartsWith(prefix, StringComparison.Ordinal)
+ && not (GeneratedNames.IsHotReloadGenerationSuffixedName name))
+
+ let derivedRows =
+ encMethodDebugInfos
+ |> Map.toList
+ |> List.choose (fun (methodToken, info) ->
+ match info.Closures, Map.tryFind methodToken methodNamesByToken with
+ | [], _
+ | _, None -> None
+ | closures, Some methName ->
+ let nameBase = cleanUpGeneratedTypeName methName
+
+ let table =
+ closures
+ |> List.choose (fun closure ->
+ let chain = decodeOccurrenceKey closure.SyntaxOffset
+ let name = ClosureNameAllocator.formatGenerationSuffixedClosureName nameBase 0 chain
+
+ if Set.contains name typeDefSimpleNames then
+ Some(chain, name)
+ else
+ None)
+
+ Some(methodToken, nameBase, table))
+
+ let hasReplayOnlyCdiMethod =
+ derivedRows
+ |> List.exists (fun (_, nameBase, table) -> List.isEmpty table && hasReplayNamedTypeDef nameBase)
+
+ let derivedNameBases =
+ derivedRows
+ |> List.choose (fun (_, nameBase, table) -> if List.isEmpty table then None else Some nameBase)
+ |> Set.ofList
+
+ let stateMachineNameBases =
+ encMethodDebugInfos
+ |> Map.toSeq
+ |> Seq.choose (fun (methodToken, info) ->
+ if List.isEmpty info.StateMachineStates then
+ None
+ else
+ Map.tryFind methodToken methodNamesByToken
+ |> Option.map cleanUpGeneratedTypeName)
+ |> Set.ofSeq
+
+ let hasReplayOnlyTypeDef =
+ typeDefSimpleNames
+ |> Set.exists (fun name ->
+ let basicName = GetBasicNameOfPossibleCompilerGeneratedName name
+
+ name.IndexOf("@hotreload", StringComparison.Ordinal) >= 0
+ && not (GeneratedNames.IsHotReloadGenerationSuffixedName name)
+ && not (Set.contains basicName derivedNameBases)
+ && not (Set.contains basicName stateMachineNameBases))
+
+ if hasReplayOnlyCdiMethod || hasReplayOnlyTypeDef then
+ Map.empty
+ else
+ (Map.empty, derivedRows)
+ ||> List.fold (fun acc (methodToken, _, table) ->
+ if not (List.isEmpty table) then
+ Map.add methodToken (Map.ofList table) acc
+ else
+ acc)
+
+/// Baseline MethodDef names keyed by token, for the CDI-derived closure-name
+/// reconstruction (the CDI write side only ever attaches a map to a method whose name
+/// identifies exactly one MethodDef row, so the name here is unambiguous for any token
+/// that carries EnC debug information).
+let private methodNamesByToken (methodTokens: Map) : Map =
+ methodTokens
+ |> Map.toSeq
+ |> Seq.map (fun (key, token) -> token, key.Name)
+ |> Map.ofSeq
+
+let private createCore
+ (moduleId: Guid)
+ (ilModule: ILModuleDef)
+ (tokenMappings: ILTokenMappings)
+ (metadataSnapshot: MetadataSnapshot)
+ (ilxGenEnvironment: IlxGenEnvSnapshot option)
+ (portablePdbSnapshot: PortablePdbSnapshot option)
+ =
+ let scope = ILScopeRef.Local
+
+ let maps =
+ ilModule.TypeDefs.AsList()
+ |> List.fold (collectType tokenMappings scope []) emptyMaps
+
+ let methodSemanticsEntries =
+ collectMethodSemanticsEntries ilModule maps.MethodTokens maps.PropertyTokens maps.EventTokens
+
+ let reconstructedSynthesizedNames = collectSynthesizedNameSnapshot ilModule
+
+ // Precedence is explicit: recorded > reconstructed. A recorded snapshot is the
+ // allocation-order ground truth persisted by the flag-on compiler; the IL walk is
+ // only an old-baseline fallback and keeps its previous behavior unchanged.
+ let synthesizedNames, synthesizedNameSnapshotSource =
+ match
+ portablePdbSnapshot
+ |> Option.bind (fun snapshot -> readSynthesizedNameSnapshotFromPortablePdb snapshot.Bytes)
+ with
+ | Some recordedSnapshot -> recordedSnapshot, SynthesizedNameSnapshotSource.Recorded
+ | None -> reconstructedSynthesizedNames, SynthesizedNameSnapshotSource.Reconstructed
+
+ if traceClosureNames.Value then
+ let source =
+ match synthesizedNameSnapshotSource with
+ | SynthesizedNameSnapshotSource.Recorded -> "recorded"
+ | SynthesizedNameSnapshotSource.Reconstructed -> "reconstructed"
+
+ printfn "[fsharp-hotreload][closure-names] synthesized-name snapshot source=%s buckets=%d" source (Map.count synthesizedNames)
+
+ // The baseline PDB is already in memory here (captured alongside the emitted
+ // assembly), so the EnC CDI rows are decoded eagerly; flag-off baselines and
+ // PDBs without EnC rows decode to the empty map.
+ let encMethodDebugInfos =
+ portablePdbSnapshot
+ |> Option.map (fun snapshot -> readEncMethodDebugInfoFromPortablePdb snapshot.Bytes)
+ |> Option.defaultValue Map.empty
+
+ // Seed the committed sequence-point view from the baseline PDB. No PDB means the
+ // map stays empty and line-shift detection / active-statement remapping stay inert.
+ let sequencePointSnapshots =
+ portablePdbSnapshot
+ |> Option.map (fun snapshot -> ActiveStatementAnalysis.decodeMethodSequencePoints snapshot.Bytes)
+ |> Option.defaultValue Map.empty
+
+ {
+ ModuleId = moduleId
+ EncId = System.Guid.Empty
+ EncBaseId = System.Guid.Empty
+ NextGeneration = 1
+ Metadata = metadataSnapshot
+ TokenMappings = tokenMappings
+ TypeTokens = maps.TypeTokens
+ MethodTokens = maps.MethodTokens
+ FieldTokens = maps.FieldTokens
+ PropertyTokens = maps.PropertyTokens
+ EventTokens = maps.EventTokens
+ PropertyMapEntries = maps.PropertyMapEntries
+ EventMapEntries = maps.EventMapEntries
+ MethodSemanticsEntries = methodSemanticsEntries
+ IlxGenEnvironment = ilxGenEnvironment
+ PortablePdb = portablePdbSnapshot
+ SynthesizedNameSnapshot = synthesizedNames
+ SynthesizedNameSnapshotSource = synthesizedNameSnapshotSource
+ SynthesizedTypeShapes = maps.SynthesizedTypeShapes
+ MetadataHandles = BaselineHandleCache.Empty
+ TypeReferenceTokens = Map.empty
+ AssemblyReferenceTokens = Map.empty
+ MemberReferenceRows = Map.empty
+ TypeSpecSignatures = Map.empty
+ CustomAttributeRows = Map.empty
+ TableEntriesAdded = Array.zeroCreate tableCount
+ StringStreamLengthAdded = 0
+ UserStringStreamLengthAdded = 0
+ BlobStreamLengthAdded = 0
+ GuidStreamLengthAdded = 0
+ AddedOrChangedMethods = []
+ EncMethodDebugInfos = encMethodDebugInfos
+ // Closure-name tables are reconstructed from the CDI occurrence keys:
+ // under occurrence-derived baseline naming they are a pure function of the
+ // identity the PDB persists, so this works for baselines read back from disk
+ // in another process exactly as for in-process captures (where the capture
+ // hook additionally validates the reconstruction against the emit-time
+ // stamp -> name recording).
+ EncClosureNames =
+ deriveEncClosureNamesFromEncDebugInfos
+ encMethodDebugInfos
+ (methodNamesByToken maps.MethodTokens)
+ (collectTypeDefSimpleNames ilModule)
+ SequencePointSnapshots = sequencePointSnapshots
+ ModuleNameOffset = None
+ }
+
+let internal applyDelta
+ (baseline: FSharpEmitBaseline)
+ (deltaTableCounts: int[])
+ (deltaHeapSizes: MetadataHeapSizes)
+ (addedOrChangedMethods: AddedOrChangedMethodInfo list)
+ (encId: Guid)
+ (encBaseId: Guid)
+ (synthesizedSnapshot: Map option)
+ : FSharpEmitBaseline =
+
+ let tableCounts =
+ if deltaTableCounts.Length = tableCount then
+ deltaTableCounts
+ else
+ Array.zeroCreate tableCount
+
+ let updatedTableEntries =
+ Array.init tableCount (fun i ->
+ let previous = baseline.TableEntriesAdded[i]
+ previous + tableCounts.[i])
+
+ let updatedMetadataSnapshot =
+ // Per Roslyn DeltaMetadataWriter.cs: Blob and UserString streams are concatenated
+ // aligned to 4-byte boundaries; String stream is concatenated unaligned.
+ // Each delta #GUID stream already contains the zero-filled cumulative prefix from
+ // prior generations. Replace that prior delta contribution with the newest full
+ // stream instead of adding it again, while retaining the original PE GUID heap.
+ let originalGuidHeapSize =
+ baseline.Metadata.HeapSizes.GuidHeapSize - baseline.GuidStreamLengthAdded
+
+ let updatedHeapSizes =
+ {
+ StringHeapSize = baseline.Metadata.HeapSizes.StringHeapSize + deltaHeapSizes.StringHeapSize
+ UserStringHeapSize =
+ baseline.Metadata.HeapSizes.UserStringHeapSize
+ + align4 deltaHeapSizes.UserStringHeapSize
+ BlobHeapSize = baseline.Metadata.HeapSizes.BlobHeapSize + align4 deltaHeapSizes.BlobHeapSize
+ GuidHeapSize = originalGuidHeapSize + deltaHeapSizes.GuidHeapSize
+ }
+
+ if traceHeapOffsets.Value then
+ printfn "[fsharp-hotreload][heap-offsets] applyDelta: Updating baseline heap sizes"
+ printfn "[fsharp-hotreload][heap-offsets] Before: UserStringHeapSize = %d" baseline.Metadata.HeapSizes.UserStringHeapSize
+
+ printfn
+ "[fsharp-hotreload][heap-offsets] Delta: UserStringHeapSize = %d (aligned = %d)"
+ deltaHeapSizes.UserStringHeapSize
+ (align4 deltaHeapSizes.UserStringHeapSize)
+
+ printfn "[fsharp-hotreload][heap-offsets] After: UserStringHeapSize = %d" updatedHeapSizes.UserStringHeapSize
+ printfn "[fsharp-hotreload][heap-offsets] Generation: %d -> %d" baseline.NextGeneration (baseline.NextGeneration + 1)
+
+ let updatedTableCountsAbsolute =
+ Array.init tableCount (fun i -> baseline.Metadata.TableRowCounts.[i] + tableCounts.[i])
+
+ { baseline.Metadata with
+ HeapSizes = updatedHeapSizes
+ TableRowCounts = updatedTableCountsAbsolute
+ }
+
+ { baseline with
+ EncId = encId
+ EncBaseId = encBaseId
+ NextGeneration = baseline.NextGeneration + 1
+ ModuleNameOffset = baseline.ModuleNameOffset
+ TableEntriesAdded = updatedTableEntries
+ // Per Roslyn DeltaMetadataWriter.cs: String stream is concatenated unaligned,
+ // Blob and UserString streams are concatenated aligned to 4-byte boundaries.
+ StringStreamLengthAdded = baseline.StringStreamLengthAdded + deltaHeapSizes.StringHeapSize
+ UserStringStreamLengthAdded = baseline.UserStringStreamLengthAdded + align4 deltaHeapSizes.UserStringHeapSize
+ BlobStreamLengthAdded = baseline.BlobStreamLengthAdded + align4 deltaHeapSizes.BlobHeapSize
+ GuidStreamLengthAdded = deltaHeapSizes.GuidHeapSize
+ Metadata = updatedMetadataSnapshot
+ SynthesizedNameSnapshot =
+ match synthesizedSnapshot with
+ | Some snapshot -> snapshot
+ | None -> baseline.SynthesizedNameSnapshot
+ MethodSemanticsEntries = baseline.MethodSemanticsEntries
+ AddedOrChangedMethods =
+ (addedOrChangedMethods @ baseline.AddedOrChangedMethods)
+ |> List.distinctBy (fun info -> info.MethodToken)
+ TypeReferenceTokens = baseline.TypeReferenceTokens
+ AssemblyReferenceTokens = baseline.AssemblyReferenceTokens
+ }
+
+///
+/// Carries per-method EnC debug information forward into the next-generation baseline after a
+/// delta, mirroring how AddedOrChangedMethods chains method state: every updated or added
+/// method's entry is replaced by its occurrence data recomputed from the fresh compile, or
+/// dropped when the fresh compile produced none (fail closed — the method's lambdas must then
+/// be treated as unmappable rather than matched against stale data). Unchanged methods keep
+/// their baseline entries.
+///
+let chainEncMethodDebugInfos
+ (baseline: FSharpEmitBaseline)
+ (refreshedEncDebugInfos: Map)
+ (updatedMethodTokens: int list)
+ : FSharpEmitBaseline =
+ let chainedInfos =
+ (baseline.EncMethodDebugInfos, updatedMethodTokens)
+ ||> List.fold (fun acc methodToken ->
+ match Map.tryFind methodToken refreshedEncDebugInfos with
+ | Some info -> Map.add methodToken info acc
+ | None -> Map.remove methodToken acc)
+
+ { baseline with
+ EncMethodDebugInfos = chainedInfos
+ }
+
+///
+/// Recomputes the per-method EnC debug information from the fresh typed tree of an edited
+/// compilation, keyed by baseline MethodDef token, for chaining into the next-generation
+/// baseline (see chainEncMethodDebugInfos). Name-to-token resolution mirrors the fail-closed
+/// write-side keying: only compiled names identifying exactly one baseline MethodDef row
+/// resolve, so an entry can never attach to the wrong method. Methods added by the current
+/// delta have no baseline token yet and carry no entry.
+///
+/// Baseline MethodDef tokens keyed by method name, restricted to names identifying
+/// exactly ONE baseline MethodDef row — the shared fail-closed name -> token resolution
+/// for typed-tree-derived per-method side tables (EnC debug info, closure-name tables).
+let private tokensByUniqueMethodName (baseline: FSharpEmitBaseline) =
+ baseline.MethodTokens
+ |> Map.toSeq
+ |> Seq.groupBy (fun (key, _) -> key.Name)
+ |> Seq.choose (fun (name, entries) ->
+ match entries |> Seq.truncate 2 |> List.ofSeq with
+ | [ (_, token) ] -> Some(name, token)
+ | _ -> None)
+ |> Map.ofSeq
+
+[]
+type ImplementationFileScope =
+ | Full
+ | ReferenceChanged
+
+let private checkedImplFiles (CheckedAssemblyAfterOptimization implFiles) =
+ implFiles |> List.map (fun implFile -> implFile.ImplFile)
+
+let private implementationFileKey (CheckedImplFile(qualifiedNameOfFile = qual)) = qual.Text
+
+let private tryBuildUniqueImplementationFileLookup files =
+ let lookup, duplicateKeys =
+ ((Map.empty, Set.empty), files)
+ ||> List.fold (fun (lookup, duplicateKeys) implFile ->
+ let key = implementationFileKey implFile
+
+ if Map.containsKey key lookup then
+ lookup, Set.add key duplicateKeys
+ else
+ Map.add key implFile lookup, duplicateKeys)
+
+ if Set.isEmpty duplicateKeys then Some lookup else None
+
+let private tryReferenceChangedImplementationFilePairs baselineImplementation freshImplementation =
+ let baselineFiles = checkedImplFiles baselineImplementation
+ let freshFiles = checkedImplFiles freshImplementation
+
+ match tryBuildUniqueImplementationFileLookup baselineFiles, tryBuildUniqueImplementationFileLookup freshFiles with
+ | Some baselineLookup, Some freshLookup ->
+ if
+ baselineLookup
+ |> Map.forall (fun key _ -> Map.containsKey key freshLookup)
+ |> not
+ then
+ None
+ else
+ ((Some [], freshFiles)
+ ||> List.fold (fun changedPairsOpt freshFile ->
+ changedPairsOpt
+ |> Option.bind (fun changedPairs ->
+ match Map.tryFind (implementationFileKey freshFile) baselineLookup with
+ | None -> None
+ | Some baselineFile when obj.ReferenceEquals(baselineFile, freshFile) -> Some changedPairs
+ | Some baselineFile -> Some((baselineFile, freshFile) :: changedPairs))))
+ |> Option.map List.rev
+ | _ -> None
+
+let private scopedFreshImplFiles scope baselineImplementation freshImplementation =
+ match scope, baselineImplementation with
+ | ImplementationFileScope.ReferenceChanged, Some baselineImplementation ->
+ match tryReferenceChangedImplementationFilePairs baselineImplementation freshImplementation with
+ | Some changedPairs -> changedPairs |> List.map snd
+ | None -> checkedImplFiles freshImplementation
+ | _ -> checkedImplFiles freshImplementation
+
+let private scopedBaselineAndFreshImplFiles scope baselineImplementation freshImplementation =
+ match scope with
+ | ImplementationFileScope.ReferenceChanged ->
+ match tryReferenceChangedImplementationFilePairs baselineImplementation freshImplementation with
+ | Some changedPairs -> changedPairs |> List.map fst, changedPairs |> List.map snd
+ | None -> checkedImplFiles baselineImplementation, checkedImplFiles freshImplementation
+ | ImplementationFileScope.Full -> checkedImplFiles baselineImplementation, checkedImplFiles freshImplementation
+
+let computeRefreshedEncMethodDebugInfosWithScope
+ (g: TcGlobals)
+ (baseline: FSharpEmitBaseline)
+ (scope: ImplementationFileScope)
+ (baselineImplementation: CheckedAssemblyAfterOptimization option)
+ (implementationFiles: CheckedAssemblyAfterOptimization)
+ : Map =
+ let infosByName =
+ implementationFiles
+ |> scopedFreshImplFiles scope baselineImplementation
+ |> computeMethodEncDebugInfo g
+
+ if Map.isEmpty infosByName then
+ Map.empty
+ else
+ let tokensByUniqueName = tokensByUniqueMethodName baseline
+
+ (Map.empty, infosByName)
+ ||> Map.fold (fun acc methName info ->
+ match Map.tryFind methName tokensByUniqueName with
+ | Some methodToken -> Map.add methodToken info acc
+ | None -> acc)
+
+let computeRefreshedEncMethodDebugInfos
+ (g: TcGlobals)
+ (baseline: FSharpEmitBaseline)
+ (implementationFiles: CheckedAssemblyAfterOptimization)
+ : Map =
+ computeRefreshedEncMethodDebugInfosWithScope g baseline ImplementationFileScope.Full None implementationFiles
+
+///
+/// Re-keys name-keyed per-method closure-name tables (occurrence-chain -> closure type
+/// name, produced by ClosureNameAllocator.computeBaselineClosureNameRows in the fsc emit
+/// path) by baseline MethodDef token, for storage as FSharpEmitBaseline.EncClosureNames.
+/// Resolution is fail closed exactly like computeRefreshedEncMethodDebugInfos: only
+/// compiled names identifying exactly one baseline MethodDef row resolve, so a table can
+/// never attach to the wrong method.
+///
+let resolveClosureNameRowsByToken
+ (baseline: FSharpEmitBaseline)
+ (rowsByMethodName: Map>)
+ : Map> =
+ if Map.isEmpty rowsByMethodName then
+ Map.empty
+ else
+ let tokensByUniqueName = tokensByUniqueMethodName baseline
+
+ (Map.empty, rowsByMethodName)
+ ||> Map.fold (fun acc methName rows ->
+ match Map.tryFind methName tokensByUniqueName with
+ | Some methodToken -> Map.add methodToken rows acc
+ | None -> acc)
+
+///
+/// Re-derives the closure-name tables of a baseline whose EnC method debug information
+/// was attached AFTER creation (the checker's read-from-disk path decodes the sibling
+/// PDB as a separate input — see service.fs createBaseline). Pure re-application of the
+/// createCore derivation over the final EncMethodDebugInfos.
+///
+let deriveEncClosureNames (ilModule: ILModuleDef) (baseline: FSharpEmitBaseline) : Map> =
+ deriveEncClosureNamesFromEncDebugInfos
+ baseline.EncMethodDebugInfos
+ (methodNamesByToken baseline.MethodTokens)
+ (collectTypeDefSimpleNames ilModule)
+
+/// Per-member compiled-name -> occurrence-list view of an implementation, restricted to
+/// compiled names claimed by exactly one member binding (the shared fail-closed keying).
+let private memberOccurrencesByUniqueNameInFiles
+ (g: TcGlobals)
+ (implFiles: CheckedImplFile list)
+ : Map =
+ let allMembers =
+ implFiles |> List.collect (TypedTreeDiff.collectMemberLambdaOccurrences g)
+
+ let ambiguousNames =
+ allMembers
+ |> List.choose (fun (symbol, _) -> symbol.CompiledName)
+ |> List.countBy id
+ |> List.filter (fun (_, count) -> count > 1)
+ |> List.map fst
+ |> Set.ofList
+
+ (Map.empty, allMembers)
+ ||> List.fold (fun acc (symbol: TypedTreeDiff.SymbolId, occurrences) ->
+ match symbol.CompiledName with
+ | Some methName when not (Set.contains methName ambiguousNames) -> Map.add methName occurrences acc
+ | _ -> acc)
+
+let private memberOccurrencesByUniqueName
+ (g: TcGlobals)
+ (implementationFiles: CheckedAssemblyAfterOptimization)
+ : Map =
+ implementationFiles
+ |> checkedImplFiles
+ |> memberOccurrencesByUniqueNameInFiles g
+
+///
+/// Derives the stamp -> closure-class-name table a flag-on BASELINE compile installs
+/// before lowering: every lambda occurrence's closure class is named
+/// {memberCompiledName}@hotreload#g0_o{occurrenceChain} — a pure function of
+/// occurrence identity, so a session started from the on-disk baseline in another
+/// process can re-derive the same names from the persisted EnC CDI occurrence keys
+/// (see deriveEncClosureNamesFromEncDebugInfos). Gating mirrors the baseline CDI emission
+/// exactly, so a name is derived if and only if the corresponding occurrence key is
+/// persisted: members without a unique compiled name are dropped, and a member is
+/// dropped entirely when ANY of its occurrence chains is not CDI-encodable (depth > 2
+/// or ordinals past the packing limits) — such members keep pure sequence-replay
+/// naming, exactly like flag-off behavior, and stay fail-closed for lambda set changes.
+///
+let computeBaselineOccurrenceKeyedClosureNames (g: TcGlobals) (optimizedImpls: CheckedAssemblyAfterOptimization) : Map =
+ (Map.empty, memberOccurrencesByUniqueName g optimizedImpls)
+ ||> Map.fold (fun acc methName occurrences ->
+ let chains = occurrences |> List.map ClosureNameAllocator.occurrenceOrdinalChain
+
+ let allChainsEncodable =
+ chains |> List.forall (fun chain -> (tryEncodeOccurrenceKey chain).IsSome)
+
+ if not allChainsEncodable then
+ acc
+ else
+ let nameBase = cleanUpGeneratedTypeName methName
+
+ (acc, List.zip occurrences chains)
+ ||> List.fold (fun acc (occurrence, chain) ->
+ // Stamp 0 is the extraction's "no root lambda" sentinel and can never
+ // be a real Expr stamp; never install a name for it.
+ if occurrence.RootExprStamp = 0L then
+ acc
+ else
+ Map.add occurrence.RootExprStamp (ClosureNameAllocator.formatGenerationSuffixedClosureName nameBase 0 chain) acc))
+
+///
+/// Runs the occurrence-keyed closure name allocator for a delta compile:
+/// aligns the fresh implementation's lambda occurrences with the previous generation's
+/// (the implementation files the session chains) and assigns each fresh occurrence its
+/// closure class name — the baseline name verbatim for compatible survivors, a
+/// generation-suffixed fresh name otherwise. Returns:
+/// - the stamp -> assigned-name table to install on the compiling CompilerGlobalState
+/// (the IlxGen closure call site consults it before sequence replay), and
+/// - the refreshed per-method occurrence-chain -> name tables keyed by baseline
+/// MethodDef token, to chain into the next-generation baseline alongside the
+/// refreshed EnC debug infos (see chainClosureNameRows).
+/// Fail closed at every join: members without a unique compiled name, without a
+/// resolvable baseline MethodDef token, or without a baseline chain -> name table get no
+/// assignments (their closures keep pure sequence-replay naming) and no refreshed table.
+/// Both tables are derived deterministically from session state + the fresh typed tree,
+/// so the emit-time install (fsc hook) and the delta-emission refresh (checker) agree.
+///
+let computeOccurrenceKeyedClosureNamesWithScope
+ (g: TcGlobals)
+ (baseline: FSharpEmitBaseline)
+ (scope: ImplementationFileScope)
+ (baselineImplementation: CheckedAssemblyAfterOptimization)
+ (freshImplementation: CheckedAssemblyAfterOptimization)
+ (generation: int)
+ : Map * Map> =
+
+ if Map.isEmpty baseline.EncClosureNames then
+ Map.empty, Map.empty
+ else
+ let baselineImplFiles, freshImplFiles =
+ scopedBaselineAndFreshImplFiles scope baselineImplementation freshImplementation
+
+ let baselineOccurrencesByName =
+ memberOccurrencesByUniqueNameInFiles g baselineImplFiles
+
+ let freshOccurrencesByName = memberOccurrencesByUniqueNameInFiles g freshImplFiles
+ let tokensByUniqueName = tokensByUniqueMethodName baseline
+
+ ((Map.empty, Map.empty), freshOccurrencesByName)
+ ||> Map.fold (fun (assignedNames, refreshedRows) methName freshOccurrences ->
+ let baselineTable =
+ Map.tryFind methName tokensByUniqueName
+ |> Option.bind (fun token ->
+ Map.tryFind token baseline.EncClosureNames
+ |> Option.map (fun table -> token, table))
+
+ match freshOccurrences, baselineTable with
+ | _ :: _, Some(methodToken, namesByChain) ->
+ let baselineOccurrences =
+ Map.tryFind methName baselineOccurrencesByName |> Option.defaultValue []
+
+ let freshNameBase = cleanUpGeneratedTypeName methName
+
+ let allocation =
+ ClosureNameAllocator.allocateMemberClosureNames
+ baselineOccurrences
+ namesByChain
+ freshOccurrences
+ freshNameBase
+ generation
+
+ let baselineTableIsComplete =
+ baselineOccurrences
+ |> List.forall (fun occurrence ->
+ namesByChain
+ |> Map.containsKey (ClosureNameAllocator.occurrenceOrdinalChain occurrence))
+
+ let baselineOccurrenceChains =
+ baselineOccurrences
+ |> List.map ClosureNameAllocator.occurrenceOrdinalChain
+ |> Set.ofList
+
+ let baselineOccurrenceByChain =
+ baselineOccurrences
+ |> List.map (fun occurrence -> ClosureNameAllocator.occurrenceOrdinalChain occurrence, occurrence)
+ |> Map.ofList
+
+ let replayAssignments =
+ allocation.Assignments
+ |> List.choose (fun (occurrence, assignment) ->
+ let occurrenceChain = ClosureNameAllocator.occurrenceOrdinalChain occurrence
+
+ match assignment with
+ | ClosureNameAllocator.ClosureNameAssignment.Reused _ when baselineTableIsComplete -> Some(occurrence, assignment)
+ | ClosureNameAllocator.ClosureNameAssignment.Reused _ ->
+ if not (Set.contains occurrenceChain baselineOccurrenceChains) then
+ Some(occurrence, assignment)
+ else
+ match Map.tryFind occurrenceChain baselineOccurrenceByChain with
+ | Some baselineOccurrence when baselineOccurrence.BodyHash <> occurrence.BodyHash ->
+ Some(
+ occurrence,
+ ClosureNameAllocator.ClosureNameAssignment.Fresh(
+ ClosureNameAllocator.formatGenerationSuffixedClosureName
+ freshNameBase
+ generation
+ occurrenceChain
+ )
+ )
+ | _ -> None
+ | ClosureNameAllocator.ClosureNameAssignment.Fresh _ when baselineTableIsComplete -> Some(occurrence, assignment)
+ | ClosureNameAllocator.ClosureNameAssignment.Fresh _ -> None)
+
+ let assignedNames =
+ (assignedNames, replayAssignments)
+ ||> List.fold (fun acc (occurrence, assignment) ->
+ // Stamp 0 is the extraction's "no root lambda" sentinel and can
+ // never be a real Expr stamp; never install a name for it.
+ if occurrence.RootExprStamp = 0L then
+ acc
+ else
+ Map.add occurrence.RootExprStamp assignment.Name acc)
+
+ let refreshedNames =
+ replayAssignments
+ |> List.map (fun (occurrence, assignment) -> ClosureNameAllocator.occurrenceOrdinalChain occurrence, assignment.Name)
+ |> Map.ofList
+
+ assignedNames, Map.add methodToken refreshedNames refreshedRows
+ | _ -> assignedNames, refreshedRows)
+
+let computeOccurrenceKeyedClosureNames
+ (g: TcGlobals)
+ (baseline: FSharpEmitBaseline)
+ (baselineImplementation: CheckedAssemblyAfterOptimization)
+ (freshImplementation: CheckedAssemblyAfterOptimization)
+ (generation: int)
+ : Map * Map> =
+ computeOccurrenceKeyedClosureNamesWithScope
+ g
+ baseline
+ ImplementationFileScope.Full
+ baselineImplementation
+ freshImplementation
+ generation
+
+///
+/// Carries the per-method closure-name tables forward into the next-generation baseline
+/// after a delta, with exactly the chainEncMethodDebugInfos semantics: every updated or
+/// added method's table is replaced by the one recomputed from the fresh compile, or
+/// dropped when the fresh compile produced none (fail closed — the method's closures
+/// then fall back to sequence replay in later generations). Unchanged methods keep their
+/// baseline tables.
+///
+let chainClosureNameRows
+ (baseline: FSharpEmitBaseline)
+ (refreshedClosureNameRows: Map>)
+ (updatedMethodTokens: int list)
+ : FSharpEmitBaseline =
+ let chainedRows =
+ (baseline.EncClosureNames, updatedMethodTokens)
+ ||> List.fold (fun acc methodToken ->
+ match Map.tryFind methodToken refreshedClosureNameRows with
+ | Some rows -> Map.add methodToken rows acc
+ | None -> Map.remove methodToken acc)
+
+ { baseline with
+ EncClosureNames = chainedRows
+ }
+
+/// Create an without capturing the ILX environment snapshot.
+let create
+ (ilModule: ILModuleDef)
+ (tokenMappings: ILTokenMappings)
+ (metadataSnapshot: MetadataSnapshot)
+ (moduleId: Guid)
+ (portablePdbSnapshot: PortablePdbSnapshot option)
+ =
+ createCore moduleId ilModule tokenMappings metadataSnapshot None portablePdbSnapshot
+
+/// Create an that carries the captured ILX environment snapshot.
+let createWithEnvironment
+ (ilModule: ILModuleDef)
+ (tokenMappings: ILTokenMappings)
+ (metadataSnapshot: MetadataSnapshot)
+ (ilxGenEnvironment: IlxGenEnvSnapshot)
+ (moduleId: Guid)
+ (portablePdbSnapshot: PortablePdbSnapshot option)
+ =
+ createCore moduleId ilModule tokenMappings metadataSnapshot (Some ilxGenEnvironment) portablePdbSnapshot
+
+// ============================================================================
+// Byte-based functions using ILBaselineReader (no SRM dependency)
+// ============================================================================
+
+/// Extract metadata snapshot from PE file bytes without using SRM.
+let metadataSnapshotFromBytes (bytes: byte[]) : MetadataSnapshot option =
+ ILBaselineReader.metadataSnapshotFromBytes bytes
+
+/// Read Module.Mvid GUID from PE file bytes without using SRM.
+let readModuleMvid (bytes: byte[]) : Guid option =
+ ILBaselineReader.readModuleMvidFromBytes bytes
+
+/// Build method handles from baseline using ILBaselineReader.
+let private buildMethodHandlesFromBytes
+ (reader: ILBaselineReader.BaselineMetadataReader)
+ (methodTokens: Map)
+ : Map =
+ methodTokens
+ |> Seq.choose (fun kvp ->
+ let key = kvp.Key
+ let token = kvp.Value
+ let rowId = token &&& 0x00FFFFFF
+
+ match reader.GetMethodDef(rowId) with
+ | None -> None
+ | Some methodDef ->
+ let firstParamRowId =
+ match reader.GetMethodParamRange(rowId) with
+ | Some(first, _) -> Some first
+ | None -> None
+
+ let result: MethodDefinitionMetadataHandles =
+ {
+ NameOffset =
+ if methodDef.NameOffset = 0 then
+ None
+ else
+ Some(StringOffset methodDef.NameOffset)
+ SignatureOffset =
+ if methodDef.SignatureOffset = 0 then
+ None
+ else
+ Some(BlobOffset methodDef.SignatureOffset)
+ FirstParameterRowId = firstParamRowId
+ Rva = Some methodDef.RVA
+ Attributes = Some(LanguagePrimitives.EnumOfValue methodDef.Flags)
+ ImplAttributes = Some(LanguagePrimitives.EnumOfValue methodDef.ImplFlags)
+ }
+
+ Some(key, result))
+ |> Map.ofSeq
+
+/// Build parameter handles from baseline using ILBaselineReader.
+let private buildParameterHandlesFromBytes
+ (reader: ILBaselineReader.BaselineMetadataReader)
+ (methodTokens: Map)
+ : Map =
+ methodTokens
+ |> Seq.collect (fun kvp ->
+ let methodKey = kvp.Key
+ let token = kvp.Value
+ let methodRowId = token &&& 0x00FFFFFF
+
+ match reader.GetMethodParamRange(methodRowId) with
+ | None -> Seq.empty
+ | Some(firstParam, lastParam) ->
+ seq {
+ for paramRowId in firstParam..lastParam do
+ match reader.GetParam(paramRowId) with
+ | None -> ()
+ | Some param ->
+ let key =
+ {
+ ParameterDefinitionKey.Method = methodKey
+ SequenceNumber = param.Sequence
+ }
+
+ let result: ParameterDefinitionMetadataHandles =
+ {
+ NameOffset =
+ if param.NameOffset = 0 then
+ None
+ else
+ Some(StringOffset param.NameOffset)
+ Name =
+ if param.NameOffset = 0 then
+ None
+ else
+ Some(reader.GetString param.NameOffset)
+ RowId = Some paramRowId
+ }
+
+ yield key, result
+ })
+ |> Map.ofSeq
+
+/// Build property handles from baseline using ILBaselineReader.
+let private buildPropertyHandlesFromBytes
+ (reader: ILBaselineReader.BaselineMetadataReader)
+ (propertyTokens: Map)
+ : Map =
+ propertyTokens
+ |> Seq.choose (fun kvp ->
+ let key = kvp.Key
+ let token = kvp.Value
+ let rowId = token &&& 0x00FFFFFF
+
+ match reader.GetProperty(rowId) with
+ | None -> None
+ | Some prop ->
+ let result: PropertyDefinitionMetadataHandles =
+ {
+ NameOffset =
+ if prop.NameOffset = 0 then
+ None
+ else
+ Some(StringOffset prop.NameOffset)
+ SignatureOffset =
+ if prop.SignatureOffset = 0 then
+ None
+ else
+ Some(BlobOffset prop.SignatureOffset)
+ }
+
+ Some(key, result))
+ |> Map.ofSeq
+
+/// Build event handles from baseline using ILBaselineReader.
+let private buildEventHandlesFromBytes
+ (reader: ILBaselineReader.BaselineMetadataReader)
+ (eventTokens: Map)
+ : Map =
+ eventTokens
+ |> Seq.choose (fun kvp ->
+ let key = kvp.Key
+ let token = kvp.Value
+ let rowId = token &&& 0x00FFFFFF
+
+ match reader.GetEvent(rowId) with
+ | None -> None
+ | Some event ->
+ let result: EventDefinitionMetadataHandles =
+ {
+ NameOffset =
+ if event.NameOffset = 0 then
+ None
+ else
+ Some(StringOffset event.NameOffset)
+ }
+
+ Some(key, result))
+ |> Map.ofSeq
+
+/// Build assembly reference tokens from baseline using ILBaselineReader.
+let private assemblyReferenceKeyFromBytes
+ (reader: ILBaselineReader.BaselineMetadataReader)
+ (assemblyRef: ILBaselineReader.AssemblyRefRowData)
+ =
+ {
+ AssemblyReferenceKey.Name = reader.GetString(assemblyRef.NameOffset)
+ MajorVersion = assemblyRef.MajorVersion
+ MinorVersion = assemblyRef.MinorVersion
+ BuildNumber = assemblyRef.BuildNumber
+ RevisionNumber = assemblyRef.RevisionNumber
+ Culture =
+ if assemblyRef.Culture = 0 then
+ ""
+ else
+ reader.GetString assemblyRef.Culture
+ PublicKeyOrToken = reader.GetBlob assemblyRef.PublicKeyOrToken |> Array.toList
+ Flags = assemblyRef.Flags
+ }
+
+/// Build assembly reference tokens from baseline using the complete AssemblyRef row identity.
+let private buildAssemblyReferenceTokensFromBytes (reader: ILBaselineReader.BaselineMetadataReader) : Map =
+ seq {
+ for rowId in 1 .. reader.AssemblyRefCount do
+ match reader.GetAssemblyRef(rowId) with
+ | Some assemblyRef ->
+ let key = assemblyReferenceKeyFromBytes reader assemblyRef
+ // AssemblyRef table index is 0x23, token = (0x23 << 24) | rowId
+ let token = (0x23 <<< 24) ||| rowId
+ yield key, token
+ | None -> ()
+ }
+ |> Map.ofSeq
+
+/// Build type reference tokens from baseline using ILBaselineReader.
+/// Keys carry the full typed scope chain (AssemblyRef identity, or the enclosing TypeRef key for
+/// nested TypeRefs) so rows with duplicate names under different scopes stay distinguishable.
+let private buildTypeReferenceTokensFromBytes (reader: ILBaselineReader.BaselineMetadataReader) : Map =
+ let keyCache = Dictionary()
+
+ // Resolution scope chains are bounded by nesting depth; guard against malformed metadata cycles.
+ let rec tryKeyForRow (rowId: int) (depth: int) : TypeReferenceKey option =
+ if depth > 64 then
+ None
+ else
+ match keyCache.TryGetValue rowId with
+ | true, cached -> cached
+ | _ ->
+ let result =
+ match reader.GetTypeRef(rowId) with
+ | None -> None
+ | Some typeRef ->
+ let (tableIndex, scopeRowId) = reader.DecodeResolutionScope(typeRef.ResolutionScope)
+
+ let scopeOpt =
+ // AssemblyRef scope (table 0x23 = 35)
+ if tableIndex = 35 then
+ reader.GetAssemblyRef(scopeRowId)
+ |> Option.map (assemblyReferenceKeyFromBytes reader >> TypeReferenceScope.Assembly)
+ // Nested TypeRef scope (table 0x01 = 1)
+ elif tableIndex = 1 && scopeRowId <> rowId then
+ tryKeyForRow scopeRowId (depth + 1) |> Option.map TypeReferenceScope.Nested
+ else
+ // Module/ModuleRef scopes have no stable cross-compilation identity here.
+ None
+
+ scopeOpt
+ |> Option.map (fun scope ->
+ {
+ TypeReferenceKey.Scope = scope
+ Namespace = reader.GetString(typeRef.NamespaceOffset)
+ Name = reader.GetString(typeRef.NameOffset)
+ })
+
+ keyCache[rowId] <- result
+ result
+
+ seq {
+ for rowId in 1 .. reader.TypeRefCount do
+ match tryKeyForRow rowId 0 with
+ | Some key ->
+ // TypeRef table index is 0x01, token = (0x01 << 24) | rowId
+ yield key, (0x01 <<< 24) ||| rowId
+ | None -> ()
+ }
+ |> Map.ofSeq
+
+let private attachMetadataHandlesFromBytesCore (bytes: byte[]) (baseline: FSharpEmitBaseline) : FSharpEmitBaseline =
+ match ILBaselineReader.BaselineMetadataReader.Create(bytes) with
+ | None -> baseline // Return unchanged if we can't read the metadata
+ | Some reader ->
+ let methodHandles = buildMethodHandlesFromBytes reader baseline.MethodTokens
+ let parameterHandles = buildParameterHandlesFromBytes reader baseline.MethodTokens
+ let propertyHandles = buildPropertyHandlesFromBytes reader baseline.PropertyTokens
+ let eventHandles = buildEventHandlesFromBytes reader baseline.EventTokens
+ let typeReferenceTokens = buildTypeReferenceTokensFromBytes reader
+ let assemblyReferenceTokens = buildAssemblyReferenceTokensFromBytes reader
+
+ let memberReferenceRows =
+ seq {
+ for rowId in 1 .. reader.MemberRefCount do
+ match reader.GetMemberRef rowId with
+ | Some row ->
+ yield
+ rowId,
+ {
+ BaselineMemberRefRow.Name = reader.GetString row.NameOffset
+ ParentToken = reader.DecodeMemberRefParentToken row.Parent
+ Signature = reader.GetBlob row.SignatureOffset
+ }
+ | None -> ()
+ }
+ |> Map.ofSeq
+
+ let typeSpecSignatures =
+ seq {
+ for rowId in 1 .. reader.TypeSpecCount do
+ match reader.GetTypeSpecSignatureOffset rowId with
+ | Some sigOffset -> yield rowId, reader.GetBlob sigOffset
+ | None -> ()
+ }
+ |> Map.ofSeq
+
+ let customAttributeRows =
+ seq {
+ for rowId in 1 .. reader.CustomAttributeCount do
+ match reader.GetCustomAttributeRow rowId with
+ | Some row ->
+ yield
+ rowId,
+ {
+ BaselineCustomAttributeRow.ParentToken = reader.DecodeHasCustomAttributeToken row.Parent
+ ConstructorToken = reader.DecodeCustomAttributeTypeToken row.Constructor
+ Value = reader.GetBlob row.ValueOffset
+ }
+ | None -> ()
+ }
+ |> Map.ofSeq
+
+ let cache =
+ {
+ MethodHandles = methodHandles
+ ParameterHandles = parameterHandles
+ PropertyHandles = propertyHandles
+ EventHandles = eventHandles
+ }
+
+ let moduleNameOffset =
+ match reader.GetModule() with
+ | Some m when m.NameOffset > 0 -> Some(StringOffset m.NameOffset)
+ | _ -> None
+
+ { baseline with
+ MetadataHandles = cache
+ ModuleNameOffset = moduleNameOffset
+ TypeReferenceTokens = typeReferenceTokens
+ AssemblyReferenceTokens = assemblyReferenceTokens
+ MemberReferenceRows = memberReferenceRows
+ TypeSpecSignatures = typeSpecSignatures
+ CustomAttributeRows = customAttributeRows
+ }
+
+/// Attach metadata handles from PE bytes without using SRM MetadataReader.
+let attachMetadataHandlesFromBytes (bytes: byte[]) (baseline: FSharpEmitBaseline) : FSharpEmitBaseline =
+ try
+ attachMetadataHandlesFromBytesCore bytes baseline
+ with
+ | :? BadImageFormatException
+ | :? IO.IOException
+ | :? ArgumentException
+ | :? IndexOutOfRangeException
+ | :? InvalidOperationException
+ | :? OverflowException -> baseline
+
+///
+/// Create a baseline directly from emitted assembly artifacts.
+/// Shared by CLI and checker entry points to keep token/heap capture behavior aligned.
+///
+let createFromEmittedArtifacts
+ (ilModule: ILModuleDef)
+ (tokenMappings: ILTokenMappings)
+ (assemblyBytes: byte[])
+ (portablePdbSnapshot: PortablePdbSnapshot option)
+ (ilxGenEnvironment: IlxGenEnvSnapshot option)
+ : FSharpEmitBaseline =
+ let moduleId =
+ readModuleMvid assemblyBytes |> Option.defaultWith System.Guid.NewGuid
+
+ let metadataSnapshot =
+ metadataSnapshotFromBytes assemblyBytes
+ |> Option.defaultWith (fun () -> failwith "Failed to read metadata from assembly bytes")
+
+ let baselineCore =
+ match ilxGenEnvironment with
+ | Some snapshot -> createWithEnvironment ilModule tokenMappings metadataSnapshot snapshot moduleId portablePdbSnapshot
+ | None -> create ilModule tokenMappings metadataSnapshot moduleId portablePdbSnapshot
+
+ attachMetadataHandlesFromBytes assemblyBytes baselineCore
diff --git a/src/Compiler/CodeGen/HotReloadPdb.fs b/src/Compiler/CodeGen/HotReloadPdb.fs
new file mode 100644
index 00000000000..2f75c639fd4
--- /dev/null
+++ b/src/Compiler/CodeGen/HotReloadPdb.fs
@@ -0,0 +1,257 @@
+/// PDB delta emission for hot reload. createSnapshot reads the baseline via the SRM-free
+/// ILBaselineReader; emitDelta serializes the Portable PDB delta through SRM's PortablePdbBuilder.
+module internal FSharp.Compiler.HotReloadPdb
+
+open System
+open System.Collections.Immutable
+open System.Collections.Generic
+open System.Reflection.Metadata
+open System.Reflection.Metadata.Ecma335
+open System.Security.Cryptography
+open FSharp.Compiler.AbstractIL.BinaryConstants
+open FSharp.Compiler.AbstractIL.ILDeltaHandles
+open FSharp.Compiler.AbstractIL.ILPdbWriter
+open FSharp.Compiler.HotReloadBaseline
+
+module ILBaselineReader = FSharp.Compiler.AbstractIL.ILBaselineReader
+
+let private shouldTracePdb () =
+ let isEnabled (name: string) =
+ match Environment.GetEnvironmentVariable(name) with
+ | null -> false
+ | value when String.Equals(value, "1", StringComparison.OrdinalIgnoreCase) -> true
+ | value when String.Equals(value, "true", StringComparison.OrdinalIgnoreCase) -> true
+ | _ -> false
+
+ isEnabled "FSHARP_HOTRELOAD_TRACE_PDB"
+ || isEnabled "FSHARP_HOTRELOAD_TRACE_METADATA"
+
+/// Create a PDB snapshot from Portable PDB bytes.
+/// Uses pure F# parsing instead of SRM for the reading path.
+let private createPortablePdbContentIdProvider (checksumAlgorithm: HashAlgorithm) : Func, BlobContentId> =
+ let algorithm =
+ match checksumAlgorithm with
+ | HashAlgorithm.Sha1 -> SHA1.Create() :> System.Security.Cryptography.HashAlgorithm
+ | HashAlgorithm.Sha256 -> SHA256.Create() :> System.Security.Cryptography.HashAlgorithm
+
+ Func, BlobContentId>(fun content ->
+ let contentBytes = content |> Seq.collect (fun c -> c.GetBytes()) |> Array.ofSeq
+ let hash = algorithm.ComputeHash contentBytes
+ BlobContentId.FromHash hash)
+
+let createSnapshot (pdbBytes: byte[]) : PortablePdbSnapshot =
+ match ILBaselineReader.readPortablePdbMetadata pdbBytes with
+ | None -> failwith "Failed to parse Portable PDB metadata"
+ | Some pdbMeta ->
+ // Convert PDB table row counts to full 64-element array
+ // PDB tables start at index 0x30
+ let counts = Array.zeroCreate DeltaTokens.TableCount
+ // pdbMeta.TableRowCounts has 8 elements (indices 0-7 map to PDB tables 0x30-0x37)
+ counts.[DeltaTokens.tableDocument] <- pdbMeta.TableRowCounts.[0]
+ counts.[DeltaTokens.tableMethodDebugInformation] <- pdbMeta.TableRowCounts.[1]
+ counts.[DeltaTokens.tableLocalScope] <- pdbMeta.TableRowCounts.[2]
+ counts.[DeltaTokens.tableLocalVariable] <- pdbMeta.TableRowCounts.[3]
+ counts.[DeltaTokens.tableLocalConstant] <- pdbMeta.TableRowCounts.[4]
+ counts.[DeltaTokens.tableImportScope] <- pdbMeta.TableRowCounts.[5]
+ counts.[DeltaTokens.tableStateMachineMethod] <- pdbMeta.TableRowCounts.[6]
+ counts.[DeltaTokens.tableCustomDebugInformation] <- pdbMeta.TableRowCounts.[7]
+
+ {
+ Bytes = Array.copy pdbBytes
+ TableRowCounts = ImmutableArray.CreateRange counts
+ EntryPointToken = pdbMeta.EntryPointToken
+ }
+
+/// Verifies that a portable PDB is the one named by the assembly's CodeView entry.
+/// A mismatched sibling PDB must never seed EnC state for another module generation.
+let matchesAssembly (assemblyBytes: byte[]) (pdbBytes: byte[]) =
+ match ILBaselineReader.readCodeViewContentIdFromBytes assemblyBytes, ILBaselineReader.readPortablePdbMetadata pdbBytes with
+ | Some expected, Some metadata -> metadata.ContentId.AsSpan().SequenceEqual(expected)
+ | _ -> false
+
+/// Creates a portable PDB snapshot only when its content ID matches the assembly.
+let tryCreateSnapshotForAssembly (assemblyBytes: byte[]) (pdbBytes: byte[]) =
+ if matchesAssembly assemblyBytes pdbBytes then
+ Some(createSnapshot pdbBytes)
+ else
+ None
+
+/// Emit a PDB delta for the given hot reload generation.
+/// Takes the metadata EncLog and EncMap (using TableName for type safety)
+/// and produces a Portable PDB delta that matches the metadata delta.
+let emitDelta
+ (baseline: FSharpEmitBaseline)
+ (updatedPdbBytes: byte[])
+ (addedOrChangedMethods: AddedOrChangedMethodInfo list)
+ (deltaToUpdatedMethodToken: IReadOnlyDictionary)
+ (_metadataEncLog: (TableName * int * EditAndContinueOperation) array)
+ (_metadataEncMap: (TableName * int) array)
+ : byte[] option =
+ match baseline.PortablePdb with
+ | None -> None
+ | Some _ ->
+ // info.MethodToken values are BASELINE-coordinate MethodDef tokens (the row the
+ // metadata delta re-emits the method at), NOT the fresh compile's tokens. Sort the
+ // distinct tokens by their BASELINE MethodDef row so the PDB MethodDebugInformation
+ // rows are appended in the same order the metadata writer sorts its method EncMap
+ // entries (FSharpDeltaMetadataWriter.fs emits Method EncMap rows using row.RowId — the
+ // baseline row — then sorts the whole EncMap ascending by token). Keeping both orders
+ // identical is the ORDERING INVARIANT: the delta's Nth MethodDebugInformation row must
+ // correspond to the Nth (baseline-row-sorted) PDB EncMap entry, or ApplyUpdate binds
+ // sequence points to the wrong method. Sorting here (rather than later, only on the
+ // EncMap) is required because for a multi-method delta after an add the fresh rows and
+ // baseline rows no longer share an order.
+ let distinctTokens =
+ addedOrChangedMethods
+ |> List.map (fun info -> info.MethodToken)
+ |> List.distinct
+ |> List.filter (fun token -> token <> 0)
+ |> List.sortBy (fun token -> MetadataTokens.GetRowNumber(MetadataTokens.MethodDefinitionHandle token))
+
+ if List.isEmpty distinctTokens then
+ if shouldTracePdb () then
+ printfn "[hotreload-pdb] distinct token list empty"
+
+ None
+ else
+ use provider =
+ MetadataReaderProvider.FromPortablePdbImage(ImmutableArray.CreateRange updatedPdbBytes)
+
+ let reader = provider.GetMetadataReader()
+ let metadata = MetadataBuilder()
+ let documentMap = Dictionary()
+ let emittedMethodRows = ResizeArray()
+ let mutable emitted = false
+
+ let getOrAddDocument (sourceHandle: DocumentHandle) =
+ match documentMap.TryGetValue sourceHandle with
+ | true, handle -> handle
+ | _ ->
+ try
+ let document = reader.GetDocument sourceHandle
+ let name = reader.GetString document.Name
+
+ let hashBytes =
+ if document.Hash.IsNil then
+ Array.empty