Skip to content

Managed ilasm: honour explicit local slots, scope local names lexically, and emit LocalScope/LocalVariable rows - #135311

Open
pcshrosbree wants to merge 3 commits into
dotnet:mainfrom
pcshrosbree:ilasm-goodwill/local-scopes
Open

pcshrosbree wants to merge 3 commits into
dotnet:mainfrom
pcshrosbree:ilasm-goodwill/local-scopes

Conversation

@pcshrosbree

@pcshrosbree pcshrosbree commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Part of making the managed ilasm (src/tools/ilasm) a drop-in replacement for native ilasm, as discussed in #135212. There @jkotas pointed to the managed rewrite as the way forward, @jkoritzinsky confirmed that PDB differences from native ilasm are unintended, and @am11 suggested combining efforts.

#135297 (PDB documents) and #135289 (the separate .pdb file) have both merged, and this branch now sits directly on main: the two commits here are the whole diff.

With the document changes (#135297) applied, the managed ilasm still failed the four TestPortablePdbLocalScope cases of the runtime's PortablePdb tests (src/tests/ilasm/PortablePdb): it passed 6 of those 10 cases, where native ilasm passes all 10. I measured this by running those cases against both ilasms on Linux x64, outside the tree, at #135297's head before this change; this PR does not change src/tests/ilasm. The cause is three defects in local handling:

  1. No LocalScope or LocalVariable rows. Before: none were written. After: as in native ilasm, each lexical scope that declares a named local gets a LocalScope row from the offset at its { to the offset at its }. The method body as a whole is a scope, and so are .try, catch, filter, finally and fault bodies. Each named local in the scope gets a LocalVariable row (Index = slot, attributes 0). Rows are sorted by start offset, then longest first. Scope information is kept whenever a PDB is requested, by any of the switches that request one.
  2. Explicit slots were dropped. Before: .locals init ([3] int32 x) was appended as the next slot. In TestLocalScopes4.il, the name LOCAL_12 and stloc.3 meant different variables, and the signature had 6 slots instead of 5. After, as in native EmitLocals:
    • [n] takes slot n, padding the table;
    • a local without [n] takes the slot after the highest one;
    • a slot freed when its scope closes can be declared again with the same type;
    • native's diagnostics apply: the "Local var slot n is in use" warning (a slot is in use from a declaration until any scope that declared it closes, as in native), and the "Local var slot n: type conflict" and "Undefined type of local var slot n in method m" errors;
    • a ... sentinel in a .locals list no longer takes a slot. Before, it took one with an invalid type byte and shifted every later local; native ilasm ignores it;
    • on parameters, [n] keeps its meaning.
  3. Nested blocks did not scope names. Before: when the method had a top-level .locals, an inner .locals declaring the same name resolved to the outer slot, and inner names stayed visible after the block. After: each block has its own name scope, searched innermost first; the first declaration of a name in a scope wins, and names are case-sensitive.

Items 2 and 3 change the emitted IL for inputs with sparse or reused slots, with shadowed or out-of-scope names, or with a sentinel in .locals. A name declared in a block and used after it is now an error, as in native ilasm. ildasm always writes sequential slots and unique names, so a round trip through ildasm cannot show these cases.

Deliberate differences from native, listed in MANAGED-ILASM-FIXES.md:

  • A scope without instructions gets no LocalScope row. Native writes length 0, and the spec requires a positive length.
  • Within a scope, each name gets a LocalVariable row at the slot its first declaration has, unless an earlier row of the scope already describes that slot. A name refers to its first declaration in the scope, so a row never describes a local that its name does not refer to. Later declarations of a name, and unnamed locals, take no part and keep no other name out. Native writes a row for every named local, so a scope that declares a name or a slot twice gets duplicate names or indices, which the spec forbids.
  • Slots run from 0 to 65535. An explicit slot outside that range (other than [-1], which means none) is reported as written and the local takes the next slot. The range is checked on the literal as written, so a literal too large for 32 bits is reported too; native reads [4294967295] as [-1] and [0x10000000000000000] as slot 0 without a diagnostic. A local whose next slot would be past 65535 is reported and not declared. Native accepts such slots, which no local instruction can name; it writes slot 65536's PDB index as 0 and does not finish on other negative values.

Other differences from native ilasm found in the same survey, not taken by any of these PRs, are tracked in #135314.

A question: the spec says a method's first scope spans the whole body. Like native ilasm, this change writes no whole-body scope when the method-level .locals names nothing (TestLocalScopes3 expects that). Should a root scope always be written instead?

A note on output: TestLocalScopes4.il now triggers the in-use warning, as it does with native ilasm. The CLI's diagnostic rendering prints the location verbosely (the whole source text); that is a separate fix.

Testing:

  • Unit tests: 71 new rows.
    • LocalTests (33): slot rules and diagnostics, including the in-use flag's native behaviour, the type a slot keeps after a conflict, out-of-range slots reported as written (including literals too large for 32 bits, one of which would otherwise read as [-1]), slot 65535 in decimal, hexadecimal and octal, and the 65535 boundary with and without a PDB; a sentinel in .locals, and a sentinel-prefixed type, which is a local; shadowing; out-of-scope names; duplicate names; case-sensitive names.
    • LocalScopeTests (38), read back with System.Reflection.Metadata:
      • scope extent: root span, block offsets, sibling blocks, a body without instructions;
      • row order: sorting, and order for the same range;
      • which locals get rows: unnamed locals and unnamed-only blocks, empty blocks, duplicate and crossed-duplicate names and slots, a later declaration of a name or an unnamed local never keeping another name's row out, case-distinct names, a sentinel, Index equal to [n] up to 65535, attributes;
      • exception-handling bodies: .try, catch, finally, filter, a block inside .try, and a program with a slot gap, a slot declared by both a .try body and a catch body, an unnamed local and a name declared again in the catch body, checked against the rows native ilasm writes for it and against its local signature;
      • per-scope ownership across methods;
      • options: every switch that requests a PDB (/DEBUG, /PDB, and the debug mode alone) gives the same rows and the same IL; /OPTIMIZE offsets; /DET byte-identical PDBs; /FOLD with each folded method keeping its own names.
    • I broke each rule in turn and checked that a test fails. Of those changes, the only ones no test notices are the PDB-only allocation gates, which do not change any output.
  • Generated cases: 200 seeded programs (196 of them with LocalScope rows) with blocks nested up to four deep, explicit, padded and reused slots, unnamed locals, and shadowed and duplicated names. The theories compare the local signature, every named reference and the LocalScope/LocalVariable rows (including their count) against a model of native ilasm's rules, and check that the table is sorted and that each method's scopes nest or are disjoint. That is 5 theories, 1,000 rows.
  • Comparison with native ilasm, measured outside the tree: I ran the runtime's PortablePdb cases (src/tests/ilasm/PortablePdb) against both ilasms on Linux x64, with the four TestPortablePdbLocalScope cases also run against the managed ilasm, and a fifth local-scope case for a slot gap, a reused slot, an unnamed local, .try and catch bodies and a shadowed name. Before this change the managed ilasm failed all five; after it, both ilasms pass all 14 IlasmPortablePdbTester cases, so the managed ilasm passes the original 10 where it passed 6. The fifth case is now the LocalScopeTests fact SlotGapReusedSlotAndShadowedNameInTryAndCatchBodies_GiveTheRowsOfNativeIlasm. This PR changes nothing under src/tests/ilasm.

Note

This change was prepared with AI assistance (Anthropic Claude and OpenAI Codex) under my direction. AI agents wrote the code and ran the build and tests on my machine. I reviewed the change before posting.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 5 pipeline(s).
11 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @JulieLeeMSFT, @dotnet/jit-contrib
See info in area-owners.md if you want to be subscribed.

…ical block

The managed ilasm appended every .locals declaration as the next slot of the
local signature and dropped the [n] of `.locals ([n] type name)`. It also
added the names declared in a nested { } block to the enclosing scope's names
when that scope had a .locals of its own, so an inner declaration of an outer
name resolved to the outer slot, and inner names stayed visible after the
block. Both change the IL of the body.

Locals now go through a per-method slot table, as in native ilasm's
EmitLocals: a declaration without [n] takes the slot after the highest one;
[n] takes slot n, padding the table up to it; the local signature has one
entry per slot in slot order. Redeclaring a slot that is in use reports
"Local var slot n is in use" (warning); as in native ilasm, a slot is in use
from a declaration until any scope that declared it closes. Redeclaring a
slot with another type reports "Local var slot n: type conflict", and the
slot takes the new type; a slot that no declaration types reports "Undefined
type of local var slot n in method m" and is written as int32. [-1] means no
explicit slot, as in native; an explicit slot outside the 16-bit range is
reported as written and the local takes the next slot, and a local whose next
slot would be past 65535 is reported and not declared. The range is checked
on the literal as written, so a literal too large for 32 bits is reported
too, rather than narrowed to a slot in range or to -1. A ... sentinel
standing alone in a .locals list no longer takes a slot, as in native ilasm.
On parameters, [n] keeps its meaning as a raw attribute value.

Each { } block, including .try, catch, filter, finally and fault bodies, now
opens a name scope that closes with it. A name resolves to the first
declaration of that name in the innermost open scope that declares it; names
are case-sensitive.

Tests: unit tests for each rule, and seeded generated methods with nested
blocks, explicit, padded and reused slots and shadowed names, checked against
a model of native ilasm's rules.
The managed ilasm wrote no LocalScope or LocalVariable rows, so debuggers saw
no local names. Each lexical scope of a method body (the body itself and each
{ } block, including .try, catch, filter, finally and fault bodies) is now
recorded when it closes, with its IL offsets and the named locals it declares,
and the PDB gets the rows native ilasm writes: one LocalScope per scope that
declares a named local, spanning the offsets at its { and } (the whole body
for the method's own scope), followed by one LocalVariable per named local
with the local's slot as Index and attributes 0. Unnamed locals get no row.
The scopes are recorded whenever a PDB is requested, by any of the switches
that request one.

The rows are sorted by start offset, then longest first, as the table
requires; scopes with the same range keep source order. Two cases where
native ilasm writes rows the specification does not allow are left out: a
scope without instructions (zero length), and a second variable with the
same name or index in one scope. Within a scope, each name gets a row at the
slot of its first declaration, the declaration the name refers to, unless an
earlier row of the scope already describes that slot; later declarations of
a name, and unnamed locals, take no part. So a row never describes a local
that its name does not refer to. Import scopes and local constants are not
recorded, as in native ilasm.

Tests: LocalScopeTests and LocalTests, and seeded generated cases that compare the
local signature, named references and LocalScope/LocalVariable rows with a model
of native ilasm's rules.
@pcshrosbree
pcshrosbree force-pushed the ilasm-goodwill/local-scopes branch from 9a7d2bd to 4d34622 Compare October 15, 2026 20:53
The [n] of a .locals declaration was read by a digit loop of its own, so
that a literal past 32 bits is checked against the slot range rather
than narrowed by the int32 rule. It now takes the bases of the int32
rule (0x is hexadecimal, any other leading 0 is octal) and reads a
decimal or hexadecimal magnitude with ulong.TryParse, whose parse fails
only for a magnitude past 64 bits. An octal literal, for which
NumberStyles has no option, is read by the int32 rule's own reader,
which keeps only the low 64 bits of a longer literal; since 65535 is
0177777, an octal literal with more than six significant digits is past
the slot range whatever those bits are. So a literal of any base outside
the range, of any length, is reported as before, and none reads as a
slot in it or as -1. An invalid octal literal is still reported once, by
the int32 rule, and keeps its value.

Tests: rows for 0xFFFFFFFFFFFFFFFF, 18446744073709551615 and
01777777777777777777777, which a 64-bit read takes as -1, and for
02000000000000000000000 and -02000000000000000000000, which it takes as
0; rows for in-range hexadecimal and octal literals with many leading
zeros; and an invalid octal slot, which is reported only as an invalid
literal.
@jkoritzinsky
jkoritzinsky enabled auto-merge (squash) October 15, 2026 22:03

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-ILTools-coreclr community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants