Skip to content

refactor: AbstractIL EnC foundations (hot reload stack 1/n)#2

Draft
NatElkins wants to merge 3 commits into
mainfrom
hot-reload-pr1-foundations
Draft

refactor: AbstractIL EnC foundations (hot reload stack 1/n)#2
NatElkins wants to merge 3 commits into
mainfrom
hot-reload-pr1-foundations

Conversation

@NatElkins

@NatElkins NatElkins commented Jun 12, 2026

Copy link
Copy Markdown
Owner

AbstractIL EnC foundations (hot reload stack 1/n)

First PR of the F# hot reload stack: the behavior-neutral AbstractIL/ilwrite groundwork
that Edit-and-Continue delta emission builds on. No feature flag, no behavior change, no
public surface change — evidence below.

Context: the complete, working implementation this slice comes from is reviewable as
PR #3 (inspection overview), and you can
try the end-to-end dotnet watch experience via
docs/hot-reload-quickstart.md.
The design docs (architecture/entity model, closure mapping, emission templates,
capabilities, active statements) are linked from PR #3's description.

What's in this slice (9 files, +2,234/−92)

1. ilwrite.fs/.fsi — structural refactor with an EnC seam (commit 1)

  • MetadataTable<'T> converted from a record to a sealed class (call-site changes:
    KeyValueSeq/Name accessors), making table row bookkeeping abstractable
  • A new IEncLogWriter seam: the metadata writer reports row additions/updates through
    an interface whose default implementation (createNullEncLogWriter) is a no-op — zero
    cost and zero behavior on every existing path. EnC delta writers (later in the stack)
    implement it to record EncLog/EncMap entries
  • MetadataHeapSizes/MetadataSnapshot: a read-only snapshot of emitted table row counts +
    heap sizes, exposed via an optional snapshot sink (writeBinaryAuxWithSnapshotSink,
    default no-op) and WriteILBinaryInMemoryWithArtifacts — this is how a baseline's exact
    layout is captured for later delta computation
  • Readability renames (ttag) and EncodeMethodBody factoring in touched code

2. New pure AbstractIL modules (commit 2) — not yet referenced by the product

File Purpose
ILDeltaHandles.fs Typed row handles + ECMA-335 coded-index unions + EditAndContinueOperation (the EncLog op codes the CLR's ApplyDelta expects)
ILEncLogWriter.fs IEncLogWriter implementations: no-op default + a recording DeltaEncLogWriter
ILMetadataHeaps.fs Heap offset/sizing helpers for delta heaps (absolute-offset semantics per the EnC spec)
ILRowIndexing.fs Row-id/coded-index arithmetic shared by readers and writers
ILBaselineReader.fs Byte-level reader for a compiled baseline's metadata tables + portable PDB (row counts, heap sizes, token maps)

All internal. They are intentionally unused here so this PR stays purely structural; the
next slice (the hot reload session entity + classification) is their first consumer.

3. Release notes entry (commit 3)

Evidence of behavior-neutrality

  • Full FSharp.slnx Debug build: 0 errors / 0 warnings
  • EmittedIL baseline suite: 1,214 passed / 0 failed (3 pre-existing skips) — emitted IL
    is byte-equivalent to main
  • Surface area test: green, no public API change (everything added is internal)
  • ilverify: n/a for this slice (no IL shape changes); the EmittedIL suite is the byte-level gate

The stack

  1. This PR — AbstractIL/ilwrite foundations
  2. Session entity + edit classification (the architectural review; small)
  3. Delta emission engine (metadata/IL/PDB EnC triplets)
  4. Closure/lambda mapping, state machines, generics, member additions, active statements —
    each with its tests and design doc

…Log writer seam

Restructures the AbstractIL writer so that Edit-and-Continue delta
emission can later plug into it without changing emitted output:

- MetadataTable<'T> becomes a sealed class with encapsulated storage;
  external consumers go through Count/Entries/KeyValueSeq instead of
  reaching into the record fields.
- Method body encoding is factored into EncodedMethodBody/EncodeMethodBody
  so the same encoding path can be reused for delta method bodies.
- writeBinaryAux gains a snapshot sink seam
  (writeBinaryAuxWithSnapshotSink); the default path passes a no-op sink,
  and WriteILBinaryInMemoryWithArtifacts exposes token mappings plus a
  MetadataSnapshot (heap sizes, table row counts, GUID heap start).
- New ILDeltaHandles module provides typed row handles, coded-index
  unions, and EnC operation values; new ILEncLogWriter module defines
  the IEncLogWriter abstraction with a no-op NullEncLogWriter used by
  full-assembly emission and a recording DeltaEncLogWriter for future
  delta emission.

The new entry points are internal and not yet used by the product.
Full-assembly emission behavior is unchanged and output stays
byte-identical.
Adds internal modules that read baseline metadata needed to seed
Edit-and-Continue delta generation:

- ILBaselineReader parses the metadata root, stream headers, table row
  counts, and heap sizes directly from PE image bytes (and the analogous
  information from portable PDB images), producing the MetadataSnapshot
  shape introduced in the previous commit. It mirrors what
  System.Reflection.Metadata's MetadataReader would provide while
  matching SRM's #Strings trimming semantics that EnC heap aggregation
  depends on.
- ILMetadataHeaps defines the IMetadataHeaps abstraction over
  string/blob/GUID/user-string heap indexing.
- ILRowIndexing defines the IRowIndexStrategy abstraction for row ID
  assignment, with the sequential full-assembly implementation.

These modules are internal and not yet used by the product; they are
the reading-side foundation for delta emission that follows in
subsequent changes. No behavior change.
@NatElkins

Copy link
Copy Markdown
Owner Author

The single-branch upstream draft PR is now open: dotnet#19941 — that's the one place for review feedback. This foundations PR stays as the first carve-out for the stacked-PR sequencing described there.

NatElkins added a commit that referenced this pull request Jul 2, 2026
Two synthesized-closure families could not be aligned across compiles after a
line-adding edit, so such edits always fell back to restart on closure-dense
real-world files (reproduced on Giraffe's endpoint list, failing identically on
the external and in-process paths):
- debug-pipe closures embed the source line in the basic name itself
  ('Pipe #1 stage #2 at line 28'), so a line shift changes the name and the
  fresh type has no baseline counterpart;
- value-binding list closures use sequential ordinals (endpoints@hotreload-N)
  allocated in encounter order, so shifts make one fresh name match several
  baseline candidates.

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

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

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

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

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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant